Repository: reflex-dev/reflex
Pull request: Support Form controls via name attribute (no ID or ref)
Dynamic refs / ids don't really work... but dynamic `name` is not a problem!

```python
import reflex as rx


class State(rx.State):
    n_fields: int = 0
    grid: int = 1

    def handle_submit(self, form_data: dict):
        print(form_data)

    def add_field(self):
        self.n_fields += 1

    def add_grid(self):
        self.grid += 1


def index() -> rx.Component:
    return rx.vstack(
        rx.color_mode_button(rx.color_mode_icon(), float="right"),
        rx.form(
            rx.input(id="text", placeholder="text"),
            #rx.input(name="text", placeholder="text"),
            rx.pin_input(id="pin", length=4),
            rx.input(name="foo", placeholder="foo"),
            rx.button("+", on_click=State.add_field),
            rx.box(
                rx.foreach(
                    rx.Var.range(State.n_fields),
                    lambda i: rx.input(name=f"field_{i}", placeholder=f"field {i}", key=i)
                ),
                width="70vw",
            ),
            rx.button("+", on_click=State.add_grid),
            rx.responsive_grid(
                rx.foreach(
                    rx.Var.range(State.grid),
                    lambda i: rx.foreach(
                        rx.Var.range(State.grid),
                        lambda j: rx.input(
                            name=f"grid_{i}_{j}", placeholder=f"grid {i} {j}", key=f"{i}_{j}", width="4em",
                        ),
                    ),
                ),
                columns=[State.grid],
            ),
            rx.button("Submit", type_="submit"),
            reset_on_submit=True,
            on_submit=State.handle_submit,
        ),
    )


# Add state and page to the app.
app = rx.App()
app.add_page(index)
app.compile()
```

This PR adds a custom handleSubmit function that all forms will use, which pre-processes the fields present in the submit event via `FormData` API. It combines the form data with the ref data, allowing both methods to interoperate without breaking any existing code.

New form-based code should prefer to use the "name" attribute to avoid the overhead of using refs for no specific gain (unless focus or value setting is required).

Closes #2074

Problem statement:
Short-hand for clearing a form after submitting
Please create an alternative method that can set all the elements inside a form to "" after the on_submit takes place.

We used reflex in CalHacks 10.0, and this is one of the enhancements we think would be great!

Hints from issue discussion:

Commit message: form: useCallback to memoize handleSubmit

Patch to review:
diff --git a/integration/test_form_submit.py b/integration/test_form_submit.py
index a9611ecc175..b8d0fd0bde3 100644
--- a/integration/test_form_submit.py
+++ b/integration/test_form_submit.py
@@ -7,6 +7,7 @@
 from selenium.webdriver.common.keys import Keys
 
 from reflex.testing import AppHarness
+from reflex.utils import format
 
 
 def FormSubmit():
@@ -36,6 +37,8 @@ def index():
                     rx.number_input(id="number_input"),
                     rx.checkbox(id="bool_input"),
                     rx.switch(id="bool_input2"),
+                    rx.checkbox(id="bool_input3"),
+                    rx.switch(id="bool_input4"),
                     rx.slider(id="slider_input"),
                     rx.range_slider(id="range_input"),
                     rx.radio_group(["option1", "option2"], id="radio_input"),
@@ -57,11 +60,65 @@ def index():
     app.compile()
 
 
-@pytest.fixture(scope="session")
-def form_submit(tmp_path_factory) -> Generator[AppHarness, None, None]:
+def FormSubmitName():
+    """App with a form using on_submit."""
+    import reflex as rx
+
+    class FormState(rx.State):
+        form_data: dict = {}
+
+        def form_submit(self, form_data: dict):
+            self.form_data = form_data
+
+    app = rx.App(state=FormState)
+
+    @app.add_page
+    def index():
+        return rx.vstack(
+            rx.input(
+                value=FormState.router.session.client_token,
+                is_read_only=True,
+                id="token",
+            ),
+            rx.form(
+                rx.vstack(
+                    rx.input(name="name_input"),
+                    rx.hstack(rx.pin_input(length=4, name="pin_input")),
+                    rx.number_input(name="number_input"),
+                    rx.checkbox(name="bool_input"),
+                    rx.switch(name="bool_input2"),
+                    rx.checkbox(name="bool_input3"),
+                    rx.switch(name="bool_input4"),
+                    rx.slider(name="slider_input"),
+                    rx.range_slider(name="range_input"),
+                    rx.radio_group(["option1", "option2"], name="radio_input"),
+                    rx.select(["option1", "option2"], name="select_input"),
+                    rx.text_area(name="text_area_input"),
+                    rx.input(
+                        name="debounce_input",
+                        debounce_timeout=0,
+                        on_change=rx.console_log,
+                    ),
+                    rx.button("Submit", type_="submit"),
+                ),
+                on_submit=FormState.form_submit,
+                custom_attrs={"action": "/invalid"},
+            ),
+            rx.spacer(),
+            height="100vh",
+        )
+
+    app.compile()
+
+
+@pytest.fixture(
+    scope="session", params=[FormSubmit, FormSubmitName], ids=["id", "name"]
+)
+def form_submit(request, tmp_path_factory) -> Generator[AppHarness, None, None]:
     """Start FormSubmit app at tmp_path via AppHarness.
 
     Args:
+        request: pytest request fixture
         tmp_path_factory: pytest tmp_path_factory fixture
 
     Yields:
@@ -69,7 +126,7 @@ def form_submit(tmp_path_factory) -> Generator[AppHarness, None, None]:
     """
     with AppHarness.create(
         root=tmp_path_factory.mktemp("form_submit"),
-        app_source=FormSubmit,  # type: ignore
+        app_source=request.param,  # type: ignore
     ) as harness:
         assert harness.app_instance is not None, "app is not running"
         yield harness
@@ -102,6 +159,7 @@ async def test_submit(driver, form_submit: AppHarness):
         form_submit: harness for FormSubmit app
     """
     assert form_submit.app_instance is not None, "app is not running"
+    by = By.ID if form_submit.app_source is FormSubmit else By.NAME
 
     # get a reference to the connected client
     token_input = driver.find_element(By.ID, "token")
@@ -111,7 +169,7 @@ async def test_submit(driver, form_submit: AppHarness):
     token = form_submit.poll_for_value(token_input)
     assert token
 
-    name_input = driver.find_element(By.ID, "name_input")
+    name_input = driver.find_element(by, "name_input")
     name_input.send_keys("foo")
 
     pin_inputs = driver.find_elements(By.CLASS_NAME, "chakra-pin-input")
@@ -136,7 +194,7 @@ async def test_submit(driver, form_submit: AppHarness):
     textarea_input = driver.find_element(By.CLASS_NAME, "chakra-textarea")
     textarea_input.send_keys("Some", Keys.ENTER, "Text")
 
-    debounce_input = driver.find_element(By.ID, "debounce_input")
+    debounce_input = driver.find_element(by, "debounce_input")
     debounce_input.send_keys("bar baz")
 
     time.sleep(1)
@@ -151,11 +209,16 @@ async def get_form_data():
     form_data = await AppHarness._poll_for_async(get_form_data)
     assert isinstance(form_data, dict)
 
+    form_data = format.collect_form_dict_names(form_data)
+
     assert form_data["name_input"] == "foo"
     assert form_data["pin_input"] == pin_values
     assert form_data["number_input"] == "-3"
-    assert form_data["bool_input"] is True
-    assert form_data["bool_input2"] is True
+    assert form_data["bool_input"]
+    assert form_data["bool_input2"]
+    assert not form_data.get("bool_input3", False)
+    assert not form_data.get("bool_input4", False)
+
     assert form_data["slider_input"] == "50"
     assert form_data["range_input"] == ["25", "75"]
     assert form_data["radio_input"] == "option2"
diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js
index 7edc35effd2..492aed94622 100644
--- a/reflex/.templates/web/utils/state.js
+++ b/reflex/.templates/web/utils/state.js
@@ -563,7 +563,7 @@ export const getRefValues = (refs) => {
     return;
   }
   // getAttribute is used by RangeSlider because it doesn't assign value
-  return refs.map((ref) => ref.current.value || ref.current.getAttribute("aria-valuenow"));
+  return refs.map((ref) => ref.current ? ref.current.value || ref.current.getAttribute("aria-valuenow") : null);
 }
 
 /**
diff --git a/reflex/components/forms/button.py b/reflex/components/forms/button.py
index 517d76c14ca..f8990f0a949 100644
--- a/reflex/components/forms/button.py
+++ b/reflex/components/forms/button.py
@@ -56,6 +56,9 @@ class Button(ChakraComponent):
     # Components that are not allowed as children.
     invalid_children: List[str] = ["Button", "MenuButton"]
 
+    # The name of the form field
+    name: Var[str]
+
 
 class ButtonGroup(ChakraComponent):
     """A group of buttons."""
diff --git a/reflex/components/forms/checkbox.py b/reflex/components/forms/checkbox.py
index 9444ed5ff90..51bf37a49d0 100644
--- a/reflex/components/forms/checkbox.py
+++ b/reflex/components/forms/checkbox.py
@@ -50,6 +50,9 @@ class Checkbox(ChakraComponent):
     # The name of the input field in a checkbox (Useful for form submission).
     name: Var[str]
 
+    # The value of the input field when checked (use is_checked prop for a bool)
+    value: Var[str] = Var.create(True)  # type: ignore
+
     # The spacing between the checkbox and its label text (0.5rem)
     spacing: Var[str]
 
diff --git a/reflex/components/forms/form.py b/reflex/components/forms/form.py
index 2572c5746c7..d785cbab8b2 100644
--- a/reflex/components/forms/form.py
+++ b/reflex/components/forms/form.py
@@ -1,11 +1,36 @@
 """Form components."""
+from __future__ import annotations
 
 from typing import Any, Dict
 
+from jinja2 import Environment
+
 from reflex.components.component import Component
 from reflex.components.libs.chakra import ChakraComponent
+from reflex.components.tags import Tag
 from reflex.constants import EventTriggers
-from reflex.vars import Var
+from reflex.event import EventChain
+from reflex.utils import imports
+from reflex.utils.format import format_event_chain
+from reflex.utils.serializers import serialize
+from reflex.vars import BaseVar, Var, get_unique_variable_name
+
+FORM_DATA = Var.create("form_data")
+HANDLE_SUBMIT_JS_JINJA2 = Environment().from_string(
+    """
+    const handleSubmit{{ handle_submit_unique_name }} = useCallback((ev) => {
+        const $form = ev.target
+        ev.preventDefault()
+        const {{ form_data }} = {...Object.fromEntries(new FormData($form).entries()), ...{{ field_ref_mapping }}}
+
+        {{ on_submit_event_chain }}
+
+        if ({{ reset_on_submit }}) {
+            $form.reset()
+        }
+    })
+    """
+)
 
 
 class Form(ChakraComponent):
@@ -16,12 +41,49 @@ class Form(ChakraComponent):
     # What the form renders to.
     as_: Var[str] = "form"  # type: ignore
 
-    def get_event_triggers(self) -> Dict[str, Any]:
-        """Get the event triggers that pass the component's value to the handler.
+    # If true, the form will be cleared after submit.
+    reset_on_submit: Var[bool] = False  # type: ignore
+
+    # The name used to make this form's submit handler function unique
+    handle_submit_unique_name: Var[str] = get_unique_variable_name()  # type: ignore
+
+    def _get_imports(self) -> imports.ImportDict:
+        return imports.merge_imports(
+            super()._get_imports(),
+            {"react": {imports.ImportVar(tag="useCallback")}},
+        )
+
+    def _get_hooks(self) -> str | None:
+        return HANDLE_SUBMIT_JS_JINJA2.render(
+            handle_submit_unique_name=self.handle_submit_unique_name,
+            form_data=FORM_DATA,
+            field_ref_mapping=serialize(self._get_form_refs()),
+            on_submit_event_chain=format_event_chain(
+                self.event_triggers[EventTriggers.ON_SUBMIT]
+            ),
+            reset_on_submit=self.reset_on_submit,
+        )
+
+    def _render(self) -> Tag:
+        return (
+            super()
+            ._render()
+            .remove_props("reset_on_submit", "handle_submit_unique_name")
+        )
+
+    def render(self) -> dict:
+        """Render the component.
 
         Returns:
-            A dict mapping the event trigger to the var that is passed to the handler.
+            The rendered component.
         """
+        self.event_triggers[EventTriggers.ON_SUBMIT] = BaseVar(
+            _var_name=f"handleSubmit{self.handle_submit_unique_name}",
+            _var_type=EventChain,
+        )
+        return super().render()
+
+    def _get_form_refs(self) -> Dict[str, Any]:
         # Send all the input refs to the handler.
         form_refs = {}
         for ref in self.get_refs():
@@ -35,10 +97,17 @@ def get_event_triggers(self) -> Dict[str, Any]:
                 form_refs[ref[4:]] = Var.create(
                     f"getRefValue({ref})", _var_is_local=False
                 )
+        return form_refs
 
+    def get_event_triggers(self) -> Dict[str, Any]:
+        """Get the event triggers that pass the component's value to the handler.
+
+        Returns:
+            A dict mapping the event trigger to the var that is passed to the handler.
+        """
         return {
             **super().get_event_triggers(),
-            EventTriggers.ON_SUBMIT: lambda e0: [form_refs],
+            EventTriggers.ON_SUBMIT: lambda e0: [FORM_DATA],
         }
 
 
diff --git a/reflex/components/forms/input.py b/reflex/components/forms/input.py
index 75bbac4cd25..b4af66b02a9 100644
--- a/reflex/components/forms/input.py
+++ b/reflex/components/forms/input.py
@@ -55,6 +55,9 @@ class Input(ChakraComponent):
     # "lg" | "md" | "sm" | "xs"
     size: Var[LiteralButtonSize]
 
+    # The name of the form field
+    name: Var[str]
+
     def _get_imports(self) -> imports.ImportDict:
         return imports.merge_imports(
             super()._get_imports(),
diff --git a/reflex/components/forms/numberinput.py b/reflex/components/forms/numberinput.py
index 1f93c6e8a1b..2fe90ad9646 100644
--- a/reflex/components/forms/numberinput.py
+++ b/reflex/components/forms/numberinput.py
@@ -68,6 +68,9 @@ class NumberInput(ChakraComponent):
     # "outline" | "filled" | "flushed" | "unstyled"
     variant: Var[LiteralInputVariant]
 
+    # The name of the form field
+    name: Var[str]
+
     def get_event_triggers(self) -> Dict[str, Any]:
         """Get the event triggers that pass the component's value to the handler.
 
diff --git a/reflex/components/forms/pininput.py b/reflex/components/forms/pininput.py
index 4d81df7f1a0..7469bbd8d76 100644
--- a/reflex/components/forms/pininput.py
+++ b/reflex/components/forms/pininput.py
@@ -4,10 +4,11 @@
 from typing import Any, Optional, Union
 
 from reflex.components.component import Component
-from reflex.components.layout import Foreach
 from reflex.components.libs.chakra import ChakraComponent, LiteralInputVariant
+from reflex.components.tags.tag import Tag
 from reflex.constants import EventTriggers
 from reflex.utils import format
+from reflex.utils.imports import ImportDict, merge_imports
 from reflex.vars import Var
 
 
@@ -58,6 +59,20 @@ class PinInput(ChakraComponent):
     # "outline" | "flushed" | "filled" | "unstyled"
     variant: Var[LiteralInputVariant]
 
+    # The name of the form field
+    name: Var[str]
+
+    def _get_imports(self) -> ImportDict:
+        """Include PinInputField explicitly because it may not be a child component at compile time.
+
+        Returns:
+            The merged import dict.
+        """
+        return merge_imports(
+            super()._get_imports(),
+            PinInputField().get_imports(),  # type: ignore
+        )
+
     def get_event_triggers(self) -> dict[str, Union[Var, Any]]:
         """Get the event triggers that pass the component's value to the handler.
 
@@ -70,13 +85,24 @@ def get_event_triggers(self) -> dict[str, Union[Var, Any]]:
             EventTriggers.ON_COMPLETE: lambda e0: [e0],
         }
 
-    def get_ref(self):
-        """Return a reference because we actually attached the ref to the PinInputFields.
+    def get_ref(self) -> str | None:
+        """Override ref handling to handle array refs.
+
+        PinInputFields may be created dynamically, so it's not possible
+        to compute their ref at compile time, so we return a cheating
+        guess if the id is specified.
+
+        The `ref` for this outer component will always be stripped off, so what
+        is returned here only matters for form ref collection purposes.
 
         Returns:
             None.
         """
-        return None
+        if any(isinstance(c, PinInputField) for c in self.children):
+            return None
+        if self.id:
+            return format.format_array_ref(self.id, idx=self.length)
+        return super().get_ref()
 
     def _get_ref_hook(self) -> Optional[str]:
         """Override the base _get_ref_hook to handle array refs.
@@ -86,10 +112,22 @@ def _get_ref_hook(self) -> Optional[str]:
         """
         if self.id:
             ref = format.format_array_ref(self.id, None)
+            refs_declaration = self.length.forrange(  # type: ignore
+                lambda _: Var.create_safe("useRef(null)", _var_is_string=False),
+            )
+            refs_declaration._var_is_local = True
             if ref:
-                return f"const {ref} = Array.from({{length:{self.length}}}, () => useRef(null));"
+                return f"const {ref} = {refs_declaration}"
             return super()._get_ref_hook()
 
+    def _render(self) -> Tag:
+        """Override the base _render to remove the fake get_ref.
+
+        Returns:
+            The rendered component.
+        """
+        return super()._render().remove_props("ref")
+
     @classmethod
     def create(cls, *children, **props) -> Component:
         """Create a pin input component.
@@ -104,22 +142,17 @@ def create(cls, *children, **props) -> Component:
         Returns:
             The pin input component.
         """
-        if not children and "length" in props:
-            _id = props.get("id", None)
-            length = props["length"]
-            if _id:
-                children = [
-                    Foreach.create(
-                        list(range(length)),  # type: ignore
-                        lambda ref, i: PinInputField.create(
-                            key=i,
-                            id=_id,
-                            index=i,
-                        ),
-                    )
-                ]
-            else:
-                children = [PinInputField()] * length
+        if children:
+            props.pop("length", None)
+        elif "length" in props:
+            field_props = {}
+            if "id" in props:
+                field_props["id"] = props["id"]
+            if "name" in props:
+                field_props["name"] = props["name"]
+            children = [
+                PinInputField.for_length(props["length"], **field_props),
+            ]
         return super().create(*children, **props)
 
 
@@ -132,6 +165,31 @@ class PinInputField(ChakraComponent):
     # Default to None because it is assigned by PinInput when created.
     index: Optional[Var[int]] = None
 
+    # The name of the form field
+    name: Var[str]
+
+    @classmethod
+    def for_length(cls, length: Var | int, **props) -> Var:
+        """Create a PinInputField for a PinInput with a given length.
+
+        Args:
+            length: The length of the PinInput.
+            props: The props of each PinInputField (name will become indexed).
+
+        Returns:
+            The PinInputField.
+        """
+        if isinstance(length, int):
+            length = Var.create_safe(length)
+        name = props.get("name")
+
+        def _create(i):
+            if name is not None:
+                props["name"] = f"{name}-{i}"
+            return PinInputField.create(**props, index=i, key=i)
+
+        return length.forrange(_create)  # type: ignore
+
     def _get_ref_hook(self) -> Optional[str]:
         return None
 
diff --git a/reflex/components/forms/radio.py b/reflex/components/forms/radio.py
index 3ee8e7b4eb2..0d4db5e88a0 100644
--- a/reflex/components/forms/radio.py
+++ b/reflex/components/forms/radio.py
@@ -23,6 +23,9 @@ class RadioGroup(ChakraComponent):
     # The default value.
     default_value: Var[Any]
 
+    # The name of the form field
+    name: Var[str]
+
     def get_event_triggers(self) -> Dict[str, Union[Var, Any]]:
         """Get the event triggers that pass the component's value to the handler.
 
diff --git a/reflex/components/forms/rangeslider.py b/reflex/components/forms/rangeslider.py
index 9879d0c693e..6eb0b688cc7 100644
--- a/reflex/components/forms/rangeslider.py
+++ b/reflex/components/forms/rangeslider.py
@@ -45,6 +45,9 @@ class RangeSlider(ChakraComponent):
     # The minimum distance between slider thumbs. Useful for preventing the thumbs from being too close together.
     min_steps_between_thumbs: Var[int]
 
+    # The name of the form field
+    name: Var[str]
+
     def get_event_triggers(self) -> dict[str, Union[Var, Any]]:
         """Get the event triggers that pass the component's value to the handler.
 
diff --git a/reflex/components/forms/select.py b/reflex/components/forms/select.py
index 15f132ba872..b56898e1b12 100644
--- a/reflex/components/forms/select.py
+++ b/reflex/components/forms/select.py
@@ -46,6 +46,9 @@ class Select(ChakraComponent):
     # The size of the select.
     size: Var[str]
 
+    # The name of the form field
+    name: Var[str]
+
     def get_event_triggers(self) -> Dict[str, Union[Var, Any]]:
         """Get the event triggers that pass the component's value to the handler.
 
diff --git a/reflex/components/forms/slider.py b/reflex/components/forms/slider.py
index 090e4c732a6..ebf21ce1430 100644
--- a/reflex/components/forms/slider.py
+++ b/reflex/components/forms/slider.py
@@ -66,6 +66,9 @@ class Slider(ChakraComponent):
     # Maximum width of the slider.
     max_w: Var[str]
 
+    # The name of the form field
+    name: Var[str]
+
     def get_event_triggers(self) -> dict[str, Union[Var, Any]]:
         """Get the event triggers that pass the component's value to the handler.
 
diff --git a/reflex/components/forms/switch.py b/reflex/components/forms/switch.py
index e6eb8272852..301a0bcec3a 100644
--- a/reflex/components/forms/switch.py
+++ b/reflex/components/forms/switch.py
@@ -34,6 +34,9 @@ class Switch(ChakraComponent):
     # The name of the input field in a switch (Useful for form submission).
     name: Var[str]
 
+    # The value of the input field when checked (use is_checked prop for a bool)
+    value: Var[str] = Var.create(True)  # type: ignore
+
     # The spacing between the switch and its label text (0.5rem)
     spacing: Var[str]
 
diff --git a/reflex/components/forms/textarea.py b/reflex/components/forms/textarea.py
index 794b26ed566..a7f3a9b3d1c 100644
--- a/reflex/components/forms/textarea.py
+++ b/reflex/components/forms/textarea.py
@@ -45,6 +45,9 @@ class TextArea(ChakraComponent):
     # "outline" | "filled" | "flushed" | "unstyled"
     variant: Var[LiteralInputVariant]
 
+    # The name of the form field
+    name: Var[str]
+
     def get_event_triggers(self) -> dict[str, Union[Var, Any]]:
         """Get the event triggers that pass the component's value to the handler.
 
diff --git a/reflex/components/tags/tag.py b/reflex/components/tags/tag.py
index 7c634436966..0122e6b96e6 100644
--- a/reflex/components/tags/tag.py
+++ b/reflex/components/tags/tag.py
@@ -83,8 +83,9 @@ def remove_props(self, *args: str) -> Tag:
             The tag with the props removed.
         """
         for name in args:
-            if name in self.props:
-                del self.props[name]
+            prop_name = format.to_camel_case(name)
+            if prop_name in self.props:
+                del self.props[prop_name]
         return self
 
     @staticmethod
diff --git a/reflex/utils/format.py b/reflex/utils/format.py
index cf65a867226..ba5c02717d5 100644
--- a/reflex/utils/format.py
+++ b/reflex/utils/format.py
@@ -625,3 +625,25 @@ def unescape_double_quotes_in_var(m: re.Match) -> str:
         string=value,
         flags=re.VERBOSE,
     )
+
+
+def collect_form_dict_names(form_dict: dict[str, Any]) -> dict[str, Any]:
+    """Collapse keys with consecutive suffixes into a single list value.
+
+    Seprators dash and underscore are removed, unless this would overwrite an existing key.
+
+    Args:
+        form_dict: The dict to collapse.
+
+    Returns:
+        The collapsed dict.
+    """
+    ending_digit_regex = re.compile(r"^(.*?)[_-]?(\d+)$")
+    collapsed = {}
+    for k in sorted(form_dict):
+        m = ending_digit_regex.match(k)
+        if m:
+            collapsed.setdefault(m.group(1), []).append(form_dict[k])
+    # collapsing never overwrites valid data from the form_dict
+    collapsed.update(form_dict)
+    return collapsed
diff --git a/reflex/vars.py b/reflex/vars.py
index 862d4433761..0735c59706f 100644
--- a/reflex/vars.py
+++ b/reflex/vars.py
@@ -1151,6 +1151,59 @@ def foreach(self, fn: Callable) -> Var:
             _var_is_local=self._var_is_local,
         )
 
+    def range(self, start: Var | int = 0) -> Var:
+        """Return an array of indicies from from start ot this var.
+
+        Args:
+            start: The start of the range.
+
+        Returns:
+            A var representing range operation.
+
+        Raises:
+            TypeError: If the var is not an int.
+        """
+        if self._var_type != int:
+            raise TypeError(f"Cannot get range on non-int var {self._var_full_name}.")
+        if isinstance(start, int):
+            start = Var.create_safe(start)
+        if start._var_type != int:
+            raise TypeError(
+                f"Cannot get start of range on non-int var {start._var_full_name}."
+            )
+
+        return BaseVar(
+            _var_name=f"Array({self._var_full_name}).fill(0).map((_, i) => i + {start})",
+            _var_type=list[self._var_type],
+            _var_is_local=False,
+        )
+
+    def forrange(self, fn: Callable) -> Var:
+        """Return a list of components. after running fn the number of times specified by this var.
+
+        Args:
+            fn: The function to call `self` times
+
+        Returns:
+            A var representing foreach operation.
+
+        Raises:
+            TypeError: If the var is not an int.
+        """
+        if self._var_type != int:
+            raise TypeError(
+                f"Cannot run forrange on non-int var {self._var_full_name}."
+            )
+        arg = BaseVar(
+            _var_name=get_unique_variable_name(),
+            _var_type=self._var_type,
+        )
+        return BaseVar(
+            _var_name=f"Array({self._var_full_name}).fill(0).map((_, {arg._var_name}) => {fn(arg)})",
+            _var_type=self._var_type,
+            _var_is_local=False,
+        )
+
     def to(self, type_: Type) -> Var:
         """Convert the type of the var.
 


Code review comments:
diff_hunk: @@ -625,3 +625,25 @@ def unescape_double_quotes_in_var(m: re.Match) -> str:
         string=value,
         flags=re.VERBOSE,
     )
+
+
+def collect_form_dict_names(form_dict: dict[str, Any]) -> dict[str, Any]:
+    """Collapse keys with consecutive suffixes into a single list value.
+
+    Seprators dash and underscore are removed, unless this would overwrite an existing key.
path: reflex/utils/format.py
text: @user1:
typo: Separators

diff_hunk: @@ -1151,6 +1151,59 @@ def foreach(self, fn: Callable) -> Var:
             _var_is_local=self._var_is_local,
         )
 
+    def range(self, start: Var | int = 0) -> Var:
+        """Return an array of indicies from from start ot this var.
+
+        Args:
+            start: The start of the range.
+
+        Returns:
+            A var representing range operation.
+
+        Raises:
+            TypeError: If the var is not an int.
+        """
+        if self._var_type != int:
+            raise TypeError(f"Cannot get range on non-int var {self._var_full_name}.")
+        if isinstance(start, int):
+            start = Var.create_safe(start)
+        if start._var_type != int:
+            raise TypeError(
+                f"Cannot get start of range on non-int var {start._var_full_name}."
+            )
+
+        return BaseVar(
+            _var_name=f"Array({self._var_full_name}).fill(0).map((_, i) => i + {start})",
+            _var_type=list[self._var_type],
+            _var_is_local=False,
+        )
+
+    def forrange(self, fn: Callable) -> Var:
path: reflex/vars.py
text: @user1:
I wonder if it makes more sense to introduce a new var if they do `rx.range(i)` which can match the API of the normal Python `range` operator. Then people can do something like

```python
rx.foreach(rx.range(5), my_function)
```

We can even then support start/stop indices

```python
rx.foreach(rx.range(1, 10), my_function)
```

Just because I think we should try to match the Python API as much as possible and not introduce new function names.

diff_hunk: @@ -625,3 +625,25 @@ def unescape_double_quotes_in_var(m: re.Match) -> str:
         string=value,
         flags=re.VERBOSE,
     )
+
+
+def collect_form_dict_names(form_dict: dict[str, Any]) -> dict[str, Any]:
+    """Collapse keys with consecutive suffixes into a single list value.
+
+    Seprators dash and underscore are removed, unless this would overwrite an existing key.
path: reflex/utils/format.py
text: @author:
```suggestion
    Separators dash and underscore are removed, unless this would overwrite an existing key.
```

diff_hunk: @@ -1151,6 +1151,59 @@ def foreach(self, fn: Callable) -> Var:
             _var_is_local=self._var_is_local,
         )
 
+    def range(self, start: Var | int = 0) -> Var:
path: reflex/vars.py
text: @user1:
Maybe instead of this we make a special function `rx.range` to match the Python API. Then we can have more flexibility