The Component Workshop: Reflex's 100 Library Components
·
Course overview
Layout
·
Box
·
Flex
·
Stack
·
Grid
·
Container
·
Section
·
Center
·
Spacer
·
Separator
·
Card
·
Inset
·
Aspect Ratio
·
Fragment
Typography
·
Text
·
Heading
·
Link
·
Code
·
Markdown
·
Blockquote
·
Quote
·
Strong
·
Em
·
Kbd
Forms
·
Button
·
Input
·
Text Area
·
Checkbox
·
Switch
·
Radio Group
·
Select
·
Slider
·
Form
·
Upload
Data display
·
Badge
·
Avatar
·
Icon
·
Callout
·
List
·
Data List
·
Code Block
·
Progress
·
Spinner
·
Scroll Area
·
Moment
Disclosure
·
Tabs
·
Accordion
·
Segmented Control
Overlay
·
Tooltip
·
Popover
·
Hover Card
·
Dialog
·
Alert Dialog
·
Drawer
·
Dropdown Menu
·
Context Menu
·
Toast
Dynamic rendering
·
Cond
·
Match
·
Foreach
·
Auto Scroll
Media
·
Image
·
Audio
·
Video
Tables and data grids
·
Table
·
Data Table
·
Data Editor
Charts
·
Area Chart
·
Bar Chart
·
Line Chart
·
Composed Chart
·
Pie Chart
·
Radar Chart
·
Radial Bar Chart
·
Scatter Chart
·
Funnel Chart
·
Treemap
·
Sankey Chart
·
Error Bar
Chart parts
·
Axis
·
Cartesian Grid
·
Chart Tooltip
·
Legend
·
Label
·
Reference
·
Brush
Other chart libraries
·
Plotly
·
Pyplot
HTML elements
·
HTML
·
HTML Text
·
HTML Layout
·
HTML Forms
·
HTML Media
·
HTML Tables
·
SVG
Other tools
·
Theme
·
Skeleton
·
Clipboard
·
Html Embed
·
Script
·
Memo
rx.formrx.form.rootrx.form.fieldrx.form.labelrx.form.controlrx.form.messagerx.form.submitOfficial docs
Workshop order
Build a café order form. It should take the name, cup size, pickup method, special requests and gift wrapping all at once and file the order.
rx.form is a frame that groups several inputs and submits them together. Put inputs such as rx.input, rx.checkbox, rx.switch, rx.slider, rx.text_area, rx.radio_group and rx.select inside it, and give each one a name. On submit, the on_submit handler receives a single {name: value} dict.
Submitting happens when you click a type="submit" button or press Enter in an input field. A submit button (or a type="submit" input) is required. With reset_on_submit=True, the fields are cleared after submitting.
Results: {}
class FormBasicState(rx.State):
form_data: dict = {}
@rx.event
def handle_submit(self, form_data: dict):
self.form_data = form_data
rx.vstack(
rx.form(
rx.vstack(
rx.input(placeholder="First Name", name="first_name"),
rx.input(placeholder="Last Name", name="last_name"),
rx.hstack(
rx.checkbox("Checked", name="check"),
rx.text(rx.flex(rx.switch(name="switch"), "Switched", spacing="2"), as_="label", size="2"),
spacing="4",
),
rx.button("Submit", type="submit"),
),
on_submit=FormBasicState.handle_submit,
reset_on_submit=True,
),
rx.divider(),
rx.text("Results: ", rx.code(FormBasicState.form_data.to_string())),
)Checkboxes and switches that are off are missing from the result. When on, they come in as "on". See "name and id" below for why.
Before submitting the form, the browser first checks conditions such as required, type="email" and min/max. Turn on no_validate and these checks are skipped; the data goes straight to the handler. Enter nothing, abc or 500 and compare the two cases.
Try the props
required
no_validate
rx.form(
rx.vstack(
rx.input(
name="email",
type="email",
placeholder="Email",
required=True,
),
rx.input(name="age", type="number", min=1, max=120, placeholder="Age (1-120)"),
rx.button("Submit", type="submit"),
rx.text("Submitted: ", rx.code(FormLabState.result), size="2"),
),
on_submit=FormLabState.handle_submit,
no_validate=False,
)| Prop | Values | Default | Use |
|---|---|---|---|
on_submit | event | On submit. Passes the form data dict (or TypedDict) | |
reset_on_submit | True / False | False | Clears the fields after submitting |
no_validate | True / False | False | Turns off browser validation |
as_child | True / False | False | Renders as the child element instead of a form |
name, id | string | none | Form name. With an id, inputs elsewhere can attach with form="id" |
action, method, enc_type, target | HTML form attributes. Reflex intercepts submission, so these are rarely used | ||
handle_submit_unique_name | string | automatic | Sets the name of this form's submit function yourself |
on_clear_server_errors | event | When server error display is cleared in a low-level form |
Form data is collected from two places. The first is name: the values the browser submits. Inputs that only have a value "when on", such as checkboxes, switches and radios, drop their key when not on. That's why you read them with form_data.get(...).
The second is id. In 0.10, inputs with an id also add their value under that id as the key, even when not on; checkboxes become True/False. Plain input, select and textarea are collected this way automatically, while an input component you build yourself needs _is_form_control = True on its class. However, the official docs announce that this id mechanism will be removed in 0.11. Going forward, it's safer to use only name.
class FormNameIdState(rx.State):
form_data: dict = {}
@rx.event
def handle_submit(self, form_data: dict):
self.form_data = form_data
rx.form(
rx.vstack(
rx.checkbox("By name", name="by_name"),
rx.checkbox("By id", id="form_by_id_box"),
rx.button("Submit", type="submit"),
rx.text("Results: ", rx.code(FormNameIdState.form_data.to_string())),
),
on_submit=FormNameIdState.handle_submit,
)When several inputs share a name, as in a group of checkboxes, the dict keeps only the last value. The official docs (latest version) type the handler argument as rx.form.FormData and show how to receive all values with getlist. But the installed 0.10 has no rx.form.FormData, so the code below is for reference only.
class ToppingsState(rx.State):
toppings: list[str] = []
@rx.event
def handle_submit(self, form_data: rx.form.FormData[str, str]):
self.toppings = form_data.getlist("topping")
def toppings_form():
return rx.form(
rx.el.input(type="checkbox", name="topping", value="cheese"),
rx.el.input(type="checkbox", name="topping", value="olives"),
rx.button("Submit", type="submit"),
on_submit=ToppingsState.handle_submit,
)In 0.10, give each input its own name (topping_cheese …) and collect them in the handler by prefix.
FORM_TOPPINGS = ["cheese", "olives", "mushrooms"]
class FormToppingState(rx.State):
toppings: list[str] = []
@rx.event
def handle_submit(self, form_data: dict):
self.toppings = [k.removeprefix("topping_") for k in form_data if k.startswith("topping_")]
rx.form(
rx.vstack(
rx.hstack(*[rx.checkbox(t, name="topping_" + t) for t in FORM_TOPPINGS], spacing="4"),
rx.button("Submit", type="submit"),
rx.hstack(rx.foreach(FormToppingState.toppings, lambda t: rx.badge(t))),
),
on_submit=FormToppingState.handle_submit,
)Type the handler argument as a TypedDict instead of dict, and your editor autocompletes the keys while Reflex checks them when the form is built. For every required key of the TypedDict, the form must contain an input with that name (or id); if even one is missing, an EventHandlerValueError is raised before the app starts. Mark optional keys with NotRequired.
from typing import TypedDict
from typing_extensions import NotRequired
class FormContactData(TypedDict):
first_name: str
last_name: str
email: str
message: NotRequired[str]
class FormTypedState(rx.State):
greeting: str = ""
@rx.event
def handle_submit(self, form_data: FormContactData):
self.greeting = f"Hi {form_data['first_name']} {form_data['last_name']} <{form_data['email']}>"
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=FormTypedState.handle_submit,
reset_on_submit=True,
),
rx.text(FormTypedState.greeting),
)Leave out the email input as below, and the error message lists the expected keys, the missing keys and the keys found. The check only runs when name is a fixed string; it is skipped for dynamic names built with rx.foreach or when the form has an id. At runtime, form_data is always a plain dict.
class SignupForm(TypedDict):
username: str
email: str
class SignupState(rx.State):
@rx.event
def handle_submit(self, form_data: SignupForm): ...
# EventHandlerValueError: the form has no control named "email".
rx.form(
rx.input(name="username"),
rx.button("Submit", type="submit"),
on_submit=SignupState.handle_submit,
)The official docs (latest version) explain that a TypedDict list[str] field collects all values with the same name, and that a bool field fills in unchecked checkboxes as False. This filling does not exist in 0.10. In 0.10, a TypedDict only does the build-time check above, and unchecked checkboxes still drop their key, so handle them with NotRequired and .get().
class PreferencesForm(TypedDict):
toppings: list[str] # every checked "toppings" checkbox
subscribe: bool # False when the checkbox is unchecked
notify: bool | None # None when the switch is offBuild inputs by looping over a State list with rx.foreach, and you get a form where the user can add fields. Added fields are all included in the submitted data. Here the number of fields is capped at 8. There are two forms side by side; the lower one only handles adding fields.
Results: {}
class FormDynamicState(rx.State):
form_data: dict = {}
form_fields: list[str] = ["first_name", "last_name", "email"]
@rx.var
def form_field_placeholders(self) -> list[str]:
return [" ".join(w.capitalize() for w in field.split("_")) for field in self.form_fields]
@rx.event
def add_form_field(self, form_data: dict):
new_field = form_data.get("new_field")
if not new_field or len(self.form_fields) >= 8:
return
field_name = new_field.strip().lower().replace(" ", "_")
if field_name and field_name not in self.form_fields:
self.form_fields.append(field_name)
@rx.event
def handle_submit(self, form_data: dict):
self.form_data = form_data
rx.vstack(
rx.form(
rx.vstack(
rx.foreach(
FormDynamicState.form_fields,
lambda field, idx: rx.input(
placeholder=FormDynamicState.form_field_placeholders[idx],
name=field,
),
),
rx.button("Submit", type="submit"),
),
on_submit=FormDynamicState.handle_submit,
reset_on_submit=True,
),
rx.form(
rx.hstack(
rx.input(placeholder="New Field", name="new_field"),
rx.button("+", type="submit", variant="soft"),
),
on_submit=FormDynamicState.add_form_field,
reset_on_submit=True,
),
rx.divider(),
rx.text("Results: ", rx.code(FormDynamicState.form_data.to_string())),
)The official docs describe the low-level form as experimental and recommend the high-level form above for production. Its advantage is that it automatically ties validation messages to the input next to them. The structure looks like this.
form.root(
form.field(
form.label(...),
form.control(...),
form.message(...),
),
form.submit(...),
)rx.form.root: the whole form. on_submit and reset_on_submit work as in the high-level form.rx.form.field: one input together with its label and messages. name becomes the name of the input inside, and server_invalid marks a failed server check.rx.form.label: the label. rx.form.control: the slot for the input. To use another component, wrap it with as_child=True. In 0.10 only rx.input fits inside a control (put checkboxes, selects and so on outside the field or directly under root).rx.form.message: a validation message. match decides which failure shows it ("valueMissing", "typeMismatch", "patternMismatch", "tooShort", "rangeOverflow" …). force_match is for server checks.rx.form.submit: the submit button. To use another button, set as_child=True.The browser checks type="email" and required, and the message with the matching match appears. If validation fails, clicking the submit button does not submit. In 0.10, rx.form.message has its text color fixed to white, which is invisible on a light background, so the example sets color explicitly.
Submitted:
class FormLowState(rx.State):
email: str = ""
@rx.event
def handle_submit(self, form_data: dict):
self.email = form_data.get("email", "")
rx.vstack(
rx.form.root(
rx.form.field(
rx.flex(
rx.form.label("Email"),
rx.form.control(
rx.input(placeholder="Email Address", type="email", required=True),
as_child=True,
),
rx.form.message("Please enter your email", match="valueMissing", color=rx.color("red", 11)),
rx.form.message("Please enter a valid email", match="typeMismatch", color=rx.color("red", 11)),
rx.form.submit(rx.button("Submit"), as_child=True),
direction="column",
spacing="2",
align="stretch",
),
name="email",
),
on_submit=FormLowState.handle_submit,
reset_on_submit=True,
width="280px",
),
rx.text("Submitted: ", rx.code(FormLowState.email), size="2"),
)Server validation is done with a computed var in the State. Connect a bool computed var meaning "invalid" to both the field's server_invalid and the message's force_match. force_match only takes effect when there is a match (0.10 drops force_match when there is no match, and a message without match is always shown). So give it match="badInput", which never occurs on its own for a text input, and let force_match decide whether it shows. Type admin below: the message appears and the button is blocked.
Signed up:
class FormServerCheckState(rx.State):
username: str = ""
submitted: str = ""
@rx.event
def set_username(self, value: str):
self.username = value
@rx.var
def username_taken(self) -> bool:
return self.username.strip().lower() in ["admin", "root", "reflex"]
@rx.event
def handle_submit(self, form_data: dict):
if not self.username_taken:
self.submitted = form_data.get("username", "")
self.username = ""
rx.vstack(
rx.form.root(
rx.form.field(
rx.flex(
rx.form.label("Username (try 'admin')"),
rx.form.control(
rx.input(
placeholder="Username",
value=FormServerCheckState.username,
on_change=FormServerCheckState.set_username,
required=True,
),
as_child=True,
),
rx.form.message(
"This name is taken",
match="badInput",
force_match=FormServerCheckState.username_taken,
color=rx.color("red", 11),
),
rx.form.submit(rx.button("Sign up", disabled=FormServerCheckState.username_taken), as_child=True),
direction="column",
spacing="2",
align="stretch",
),
name="username",
server_invalid=FormServerCheckState.username_taken,
),
on_submit=FormServerCheckState.handle_submit,
width="280px",
),
rx.text("Signed up: ", rx.code(FormServerCheckState.submitted), size="2"),
)Uploaded files are not included in the form's on_submit. If there is an Upload inside the form, send the files with rx.upload_files() from a separate type="button" button or from on_drop, and receive only the other fields through on_submit. The Upload page covers this.
Each of the five kinds of input gets a name, and a single handler receives them all. The select is required, so the form won't submit until something is picked; unchecked checkboxes drop their key, so the handler reads .get("gift") and turns it into "yes"/"no".
class FormOrderState(rx.State):
last_order: dict = {}
@rx.event
def place_order(self, form_data: dict):
self.last_order = {
"name": form_data.get("name", ""),
"size": form_data.get("size", ""),
"pickup": form_data.get("pickup", ""),
"gift": "yes" if form_data.get("gift") else "no",
"note": form_data.get("note", "")[:100],
}
rx.card(
rx.form(
rx.vstack(
rx.input(name="name", placeholder="Your name", required=True),
rx.select(["Small", "Medium", "Large"], name="size", placeholder="Cup size", required=True),
rx.radio_group(["Store", "Delivery"], name="pickup", default_value="Store"),
rx.text_area(name="note", placeholder="Note (optional)", max_length=100, rows="2"),
rx.checkbox("Gift wrap", name="gift"),
rx.button("Place order", type="submit"),
rx.cond(
FormOrderState.last_order,
rx.code_block(FormOrderState.last_order.to_string(), language="json", width="100%"),
),
align="stretch",
),
on_submit=FormOrderState.place_order,
reset_on_submit=True,
),
width="100%",
max_width="360px",
)Workshop notes
rx.form(..., on_submit=handler) + a name on every input inside. The handler receives a {name: value} dict.
A type="submit" button is required. reset_on_submit=True clears the fields after submitting.
Unchecked checkboxes, switches and radios drop their key; read them with form_data.get(). The id mechanism goes away in 0.11.
Type the handler argument as a TypedDict and missing fields are caught when the form is built.
To tie validation messages to inputs, use the low-level field, control and message (match, force_match + server_invalid).
Get news about new lessons
We'll email you when this course gets new lessons or a follow-up course opens.
R
ReflexKR
Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.
ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee
Affiliate disclosure© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Questions & answers
Ask anything about this lesson. Questions from every language version of this page appear together.