Time 2:10–2:55
By the end of this lesson, the "Add User" button will open a dialog, submitting the form will add a customer to the table, and the dialog will close on its own. This is the first, and most essential, step in making the page react to what the user does.
An event handler is a method defined in State and decorated with @rx.event. Event handlers are the only thing that can change state.
class State(rx.State):
users: list[User] = [...]
@rx.event
def add_user(self, form_data: dict):
self.users.append(User(**form_data))self.users is an actual list, not a Var, so you can freely use append, len, if, for and even third-party libraries.self.users, Reflex detects the change and sends it to the browser, and the table and badge re-render automatically. There's no code that updates the page directly.User(**form_data) unpacks the dictionary into keyword arguments: {"name": "Kim", "email": "...", "gender": "Male"} → User(name="Kim", email="...", gender="Male").Instructor note — Contrast this with the table from Lesson 3: "Inside a UI function it's a Var, so no len; inside a handler it's the real value, so len works." Once that contrast clicks, you understand half of Reflex.
rx.form(
rx.flex(
rx.input(placeholder="User Name", name="name", required=True),
rx.input(placeholder="user@reflex.dev", name="email"),
rx.select(
["Male", "Female"],
placeholder="Select gender",
default_value="Male",
name="gender",
custom_attrs={"aria-label": "Gender"},
),
rx.flex(
rx.dialog.close(
rx.button("Cancel", type="button", variant="soft", color_scheme="gray"),
),
rx.button("Submit", type="submit"),
spacing="3",
justify="end",
),
direction="column",
spacing="4",
),
on_submit=State.add_user,
reset_on_submit=False,
)name= is the shared key. On submit, each input's name becomes a key in the form_data dictionary. They must exactly match the User field names (name, email, gender) for User(**form_data) to work.on_submit=State.add_user through the class, without calling it. The dictionary the form builds is passed in as form_data automatically.required=True makes the browser block submission while the field is empty.type="button" matters. Buttons inside a form default to submit, so if you leave it out, clicking Cancel still adds a customer.rx.flex is a box with display="flex". direction="column" stacks children vertically, and spacing sets the gap.custom_attrs attaches HTML attributes that Reflex doesn't know about as-is. Here it's a label for screen readers.def add_customer_button() -> rx.Component:
return rx.dialog.root(
rx.dialog.trigger( # the element that opens the dialog when clicked
rx.button(
rx.icon("plus", size=18),
rx.text("Add User", size="2"),
),
),
rx.dialog.content( # the dialog body
rx.dialog.title("Add New User"),
rx.dialog.description("Fill the form with the user's info"),
rx.form(...), # the form from 4-2
max_width="450px",
),
)rx.icon("plus") takes a Lucide icon name as-is.add_customer_button() (after the title box, inside the same parent).Check your browser — A blue "+ Add User" button appears at the far right of the header.
localhost:3000
Try itYour customer directory and audience breakdown.
Click the button and the background dims as the dialog opens, with gender preselected as Male. Try entering values in the preview above and clicking Submit.
If you run the app at this point, you'll notice a problem: the customer gets added, but the dialog doesn't close. The browser is managing the dialog's open/closed state on its own, so the handler on the server has no way to tell it to close.
The fix is to move "is it open?" into State. This is called the controlled component pattern.
class State(rx.State):
users: list[User] = [...]
add_user_dialog_open: bool = False # 1) keep the open state in State
@rx.event
def set_add_user_dialog_open(self, open_: bool): # 2) define the setter yourself
self.add_user_dialog_open = open_
@rx.event
def add_user(self, form_data: dict):
self.users.append(User(**form_data))
self.add_user_dialog_open = False # 3) close on submitrx.dialog.root(
...,
open=State.add_user_dialog_open, # state decides whether it's open
on_open_change=State.set_add_user_dialog_open, # reflect user open/close in state
)open= and forget on_open_change, clicking the trigger button won't change the state, so the dialog never opens. The two always go together.on_open_change passes True to the handler when the dialog opens and False when it closes (Cancel, ESC or clicking outside).State.set_<var_name> without defining it, but 0.10 doesn't generate those methods, so you have to define them yourself as shown above.open_ by convention, to avoid shadowing Python's built-in open function.reset_on_submit=False, closing the dialog removes its content from the page and redraws it when reopened, so the inputs start out empty again.{"name": ..., "email": ..., "gender": ...}.add_user(form_data).users, add_user_dialog_open) come back to the browser.Check your browser — With 4-4 applied, clicking Submit closes the dialog, adds a 3rd row to the table and changes the badge to 3. If the dialog stays open, double-check open= and on_open_change= from 4-4.
localhost:3000
Try itYour customer directory and audience breakdown.
"Other" to the gender options.type="email" to the email input and see what happens when you submit an invalid address.rx.toast.error("This customer already exists") instead. On success, try returning rx.toast.success(...) too.name=, form_data and the User fields relate to each other.← Previous
Lesson 3 · State and Vars: let data draw the screen
Next →
Lesson 5 · Shaping data and charts
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.