Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
94e8c81
Support TypedDict form submit data
Apr 8, 2026
eb8b5fe
Fix TypedDict form CI followups
Apr 8, 2026
8d0d0da
Fix TypedDict form validation edge cases and code quality
FarhanAliRaza Apr 16, 2026
8308cd1
Merge branch 'main' into feat/support-typeddict-forms
FarhanAliRaza Apr 16, 2026
ce8b987
fix: handle NotRequired TypedDict fields on Python 3.10
FarhanAliRaza Apr 16, 2026
02d5565
Merge branch 'feat/support-typeddict-forms' of https://github.com/Gau…
FarhanAliRaza Apr 16, 2026
07cc0e2
test: add RED/GREEN assertions and data-driven integration tests
FarhanAliRaza Apr 16, 2026
a451210
test: fix flaky test
FarhanAliRaza Apr 16, 2026
80be5f4
pyi hashes
FarhanAliRaza Apr 16, 2026
3106bc1
Merge remote-tracking branch 'origin/main' into feat/support-typeddic…
masenf May 9, 2026
34863e0
update pyi_files
masenf May 10, 2026
c46f1ca
Apply suggestion from @greptile-apps[bot]
masenf May 11, 2026
298036a
Merge remote-tracking branch 'origin/main' into feat/support-typeddic…
masenf May 12, 2026
d1863fe
Merge remote-tracking branch 'upstream/main' into feat/support-typedd…
FarhanAliRaza May 26, 2026
1a26f42
Merge remote-tracking branch 'origin/main' into feat/support-typeddic…
masenf Jun 4, 2026
b66c039
add news fragments
masenf Jun 4, 2026
fc2686c
add docs for TypedDict in on_submit handler
masenf Jun 4, 2026
713dd72
bump min dep on reflex-base for reflex-components-{core,radix}
masenf Jun 4, 2026
5a51b0c
Merge branch 'main' into feat/support-typeddict-forms
masenf Jun 5, 2026
1013189
Merge branch 'main' into feat/support-typeddict-forms
masenf Jun 5, 2026
d803b2a
Merge remote-tracking branch 'origin/main' into feat/support-typeddic…
masenf Jun 9, 2026
4233239
Merge remote-tracking branch 'origin/main' into feat/support-typeddic…
masenf Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions docs/library/forms/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,114 @@ If you need these controls to be passed in the form data even when their values
# Video: Forms
```

## Validating Form Data with a TypedDict

The `on_submit` handler usually receives the form data as a plain `dict`, which
means accessing a field is untyped (`form_data["email"]` returns `Any`) and a
typo in a `name` goes unnoticed until runtime.

Instead, you can annotate the handler's parameter with a
[`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict).
This gives you typed, autocompleted access to each field inside the handler, and
Reflex validates the form **at compile time**: every required key of the
`TypedDict` must have a matching form control. If a required field has no
control with that `name` (or `id`), Reflex raises an `EventHandlerValueError`
before the app starts, pointing out exactly which fields are missing.

```python demo exec
from typing import TypedDict

from typing_extensions import NotRequired


class ContactForm(TypedDict):
first_name: str
last_name: str
email: str
message: NotRequired[str] # optional field


class TypedFormState(rx.State):
form_data: ContactForm | None = None

@rx.event
def handle_submit(self, form_data: ContactForm):
"""Handle the form submit."""
# form_data is typed: editors autocomplete the keys below.
self.form_data = form_data


def typed_form_example():
return rx.vstack(
rx.form(
rx.vstack(
rx.input(placeholder="First Name", name="first_name"),
rx.input(placeholder="Last Name", name="last_name"),
rx.input(placeholder="Email", name="email", type="email"),
rx.text_area(placeholder="Message", name="message"),
rx.button("Submit", type="submit"),
),
on_submit=TypedFormState.handle_submit,
reset_on_submit=True,
),
rx.divider(),
rx.heading("Results"),
rx.text(TypedFormState.form_data.to_string()),
)
```

### Required and optional fields

By default every key declared in a `TypedDict` is **required** and must be
backed by a form control. Mark a field as optional with `NotRequired` (or by
inheriting from a `total=False` base) so Reflex won't require a matching
control:

```python
from typing import TypedDict

from typing_extensions import NotRequired


class ContactForm(TypedDict):
name: str # required: a control named "name" must exist
email: str # required: a control named "email" must exist
message: NotRequired[str] # optional: no control required
```

If a required field is missing, creating the form fails fast with a message that
lists the expected, missing, and matching fields:

```python
class SignupForm(TypedDict):
username: str
email: str


class SignupState(rx.State):
@rx.event
def handle_submit(self, form_data: SignupForm): ...


# Raises EventHandlerValueError: the form has no control named "email".
rx.form(
rx.input(name="username"),
rx.button("Submit", type="submit"),
on_submit=SignupState.handle_submit,
)
```

```md alert info
# When is validation skipped?

The check only runs when the form fields are statically known. It is
automatically skipped when control `name`/`id` values are dynamic (for example,
built with `rx.foreach`), or when the form has an `id` (since controls can be
associated from elsewhere via the HTML `form` attribute). In those cases the
`TypedDict` still provides typed access inside the handler. At runtime
`form_data` is always a regular dictionary.
```

## Dynamic Forms

Forms can be dynamically created by iterating through state vars using `rx.foreach`.
Expand Down
1 change: 1 addition & 0 deletions news/6301.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`rx.form` `on_submit` handlers can now annotate their form-data parameter with a `TypedDict` (including `typing_extensions.NotRequired` fields). The submitted mapping is accepted by the event-argument type checker, and at component build time the form statically validates that its controls supply every required `TypedDict` field, raising `EventHandlerValueError` — with the missing and present field names — when a required field has no control with a matching static `name`/`id`. Validation is skipped when the form sets an `id` (controls may be associated externally via the HTML `form` attribute) or when any control identifier is a dynamic `Var`.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6301.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Event-argument type checking now treats a mapping-style payload as compatible with a `TypedDict`-annotated callback parameter, scoped narrowly to `on_submit` triggers whose payload is a `Mapping[str, ...]` so unrelated mapping events are unaffected. Adds the `FORM_SUBMIT_MAPPING` type var (exposed on the event namespace and `pyi_generator`'s default imports) and a `Component._is_form_control` class marker that a component sets to declare it contributes a named field to form submission data.
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,9 @@ class Component(BaseComponent, ABC):
# props to change the name of
_rename_props: ClassVar[dict[str, str]] = {}

# Whether this component contributes a named field to form submission data.
_is_form_control: ClassVar[bool] = False

custom_attrs: dict[str, Var | Any] = field(
doc="Attributes passed directly to the component.",
default_factory=dict,
Expand Down
68 changes: 53 additions & 15 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@
overload,
)

from typing_extensions import Self, TypeAliasType, TypedDict, TypeVarTuple, Unpack
from typing_extensions import (
Self,
TypeAliasType,
TypedDict,
TypeVarTuple,
Unpack,
is_typeddict,
)

from reflex_base import constants
from reflex_base.components.field import BaseField
Expand Down Expand Up @@ -61,6 +68,8 @@
if TYPE_CHECKING:
from reflex.state import BaseState

BASE_STATE = TypeVar("BASE_STATE", bound=BaseState)


@dataclasses.dataclass(
init=True,
Expand Down Expand Up @@ -839,6 +848,7 @@ def checked_input_event(e: ObjectVar[JavascriptInputEvent]) -> tuple[Var[bool]]:


FORM_DATA = Var(_js_expr="form_data")
FORM_SUBMIT_MAPPING = TypeVar("FORM_SUBMIT_MAPPING", bound=Mapping[str, Any])


def on_submit_event() -> tuple[Var[dict[str, Any]]]:
Expand Down Expand Up @@ -1708,6 +1718,37 @@ def _values_returned_from_event(event_spec_annotations: list[Any]) -> list[Any]:
]


def _is_on_submit_mapping_event_arg_compatible_with_typed_dict(
provided_event_arg_type: Any,
callback_param_type: Any,
key: str,
) -> bool:
"""Check whether an on_submit mapping payload can satisfy a TypedDict callback.

This keeps the compatibility relaxation scoped to form submission payloads
rather than applying to unrelated mapping-based event triggers.

Args:
provided_event_arg_type: The type produced by the event trigger.
callback_param_type: The callback parameter annotation.
key: The event trigger key being validated.

Returns:
Whether the provided event payload should be treated as compatible.
"""
if key != constants.EventTriggers.ON_SUBMIT or not is_typeddict(
callback_param_type
):
return False

mapping_type = get_origin(provided_event_arg_type) or provided_event_arg_type
if not safe_issubclass(mapping_type, Mapping):
return False

key_type = get_args(provided_event_arg_type)[:1]
return not key_type or typehint_issubclass(key_type[0], str)


def _check_event_args_subclass_of_callback(
callback_params_names: list[str],
provided_event_types: list[Any],
Expand Down Expand Up @@ -1749,15 +1790,18 @@ def _check_event_args_subclass_of_callback(
continue

type_match_found.setdefault(arg, False)
callback_param_type = callback_param_name_to_type[arg]

try:
compare_result = typehint_issubclass(
args_types_without_vars[i], callback_param_name_to_type[arg]
args_types_without_vars[i], callback_param_type
) or _is_on_submit_mapping_event_arg_compatible_with_typed_dict(
args_types_without_vars[i], callback_param_type, key
)
except TypeError as te:
callback_name_context = f" of {callback_name}" if callback_name else ""
key_context = f" for {key}" if key else ""
msg = f"Could not compare types {args_types_without_vars[i]} and {callback_param_name_to_type[arg]} for argument {arg}{callback_name_context}{key_context}."
msg = f"Could not compare types {args_types_without_vars[i]} and {callback_param_type} for argument {arg}{callback_name_context}{key_context}."
raise TypeError(msg) from te

if compare_result:
Expand All @@ -1769,7 +1813,7 @@ def _check_event_args_subclass_of_callback(
)
delayed_exceptions.append(
EventHandlerArgTypeMismatchError(
f"Event handler {key} expects {args_types_without_vars[i]} for argument {arg} but got {callback_param_name_to_type[arg]}{as_annotated_in} instead."
f"Event handler {key} expects {args_types_without_vars[i]} for argument {arg} but got {callback_param_type}{as_annotated_in} instead."
)
)

Expand Down Expand Up @@ -2650,10 +2694,6 @@ def __call__(self, *args: Var) -> Any:
if TYPE_CHECKING:
from reflex.state import BaseState
Comment thread
GautamBytes marked this conversation as resolved.

BASE_STATE = TypeVar("BASE_STATE", bound=BaseState)
else:
BASE_STATE = TypeVar("BASE_STATE")


class EventNamespace:
"""A namespace for event related classes."""
Expand Down Expand Up @@ -2700,6 +2740,7 @@ class EventNamespace:
EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER
_EVENT_FIELDS = _EVENT_FIELDS
FORM_DATA = FORM_DATA
FORM_SUBMIT_MAPPING = FORM_SUBMIT_MAPPING
upload_files = upload_files
upload_files_chunk = upload_files_chunk
stop_propagation = stop_propagation
Expand Down Expand Up @@ -2733,7 +2774,7 @@ def __new__(
@overload
def __new__(
cls,
func: Callable[[BASE_STATE, Unpack[P]], Any],
func: "Callable[[BASE_STATE, Unpack[P]], Any]",
*,
background: bool | None = None,
stop_propagation: bool | None = None,
Expand All @@ -2745,18 +2786,15 @@ def __new__(

def __new__(
cls,
func: Callable[[BASE_STATE, Unpack[P]], Any] | None = None,
func: "Callable[[BASE_STATE, Unpack[P]], Any] | None" = None,
*,
background: bool | None = None,
stop_propagation: bool | None = None,
prevent_default: bool | None = None,
throttle: int | None = None,
debounce: int | None = None,
temporal: bool | None = None,
) -> (
EventCallback[Unpack[P]]
| Callable[[Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]]]
):
) -> "EventCallback[Unpack[P]] | Callable[[Callable[[BASE_STATE, Unpack[P]], Any]], EventCallback[Unpack[P]]]":
"""Wrap a function to be used as an event.

Args:
Expand Down Expand Up @@ -2804,7 +2842,7 @@ def _build_event_actions():
return event_actions

def wrapper(
func: Callable[[BASE_STATE, Unpack[P]], T],
func: "Callable[[BASE_STATE, Unpack[P]], T]",
) -> EventCallback[Unpack[P]]:
if background is True:
if not inspect.iscoroutinefunction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def _safe_issubclass(cls: Any, cls_check: Any | tuple[Any, ...]) -> bool:
"EventHandler",
"EventSpec",
"EventType",
"FORM_SUBMIT_MAPPING",
"KeyInputInfo",
"PointerEventInfo",
],
Expand Down
1 change: 1 addition & 0 deletions packages/reflex-components-core/news/6301.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`Form` now validates statically-knowable fields against a `TypedDict`-annotated `on_submit` handler at create time: it walks nested form controls (including components nested in props), collects their static `name`/`id` values, and raises `EventHandlerValueError` listing the missing and present fields when a required `TypedDict` field has no matching control. `input`, `select`, and `textarea` are marked as form controls so their identifiers are collected, and required-field resolution honors `NotRequired` across Python 3.10 and 3.11+. The `on_submit` handler signature also accepts a mapping-style payload via `on_submit_mapping_event`.
2 changes: 1 addition & 1 deletion packages/reflex-components-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }]
maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }]
requires-python = ">=3.10"
dependencies = [
"reflex-base >= 0.9.2",
"reflex-base >= 0.9.4.post23.dev0",
"reflex-components-lucide >= 0.9.0",
"reflex-components-sonner >= 0.9.0",
"python_multipart >= 0.0.21",
Expand Down
Loading
Loading