Modal Form
When to Use
- Default for most Create and Edit forms — keeps user in context on parent screen
- Inline add of a related entity from another screen (add address from order detail)
- Quick configuration changes and single-purpose forms (rename, change status)
- Any form the design hasn't explicitly called out as a full-page routed screen
Page Implementation
tsx
function ModalForm() {
return (
<Dialog.Root>
<Dialog.Trigger render={<Button />}>Add address</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Add address</Dialog.Title>
<Dialog.Description>Add a shipping address to this order.</Dialog.Description>
</Dialog.Header>
<form
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
window.alert(`Saving ${data.get("label") ?? ""}`);
}}
>
<div className="flex flex-col gap-4 py-4">
<Field.Root name="label">
<Field.Label>Label</Field.Label>
<Field.Control render={<Input />} />
</Field.Root>
<Field.Root name="street">
<Field.Label>Street</Field.Label>
<Field.Control render={<Input />} />
</Field.Root>
<Field.Root name="city">
<Field.Label>City</Field.Label>
<Field.Control render={<Input />} />
</Field.Root>
</div>
<Dialog.Footer>
<Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
);
}Route-driven Variant
The route-driven variant renders the same component at both the parent path and the create / edit path, with onOpenChange navigating back so the URL stays in sync.
tsx
function ModalFormRouted() {
const [isCreateOpen, setCreateOpen] = useState(false);
return (
<Layout>
<Layout.Header
title="Products"
actions={[
<Button key="create" onClick={() => setCreateOpen(true)}>
Create
</Button>,
]}
/>
<Layout.Column>{/* products list — see list/dense-scan */}</Layout.Column>
<Dialog.Root open={isCreateOpen} onOpenChange={setCreateOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create product</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
window.alert(`Saving ${data.get("name") ?? ""}`);
setCreateOpen(false);
}}
>
<div className="flex flex-col gap-4 py-4">
<Field.Root name="name">
<Field.Label>Name</Field.Label>
<Field.Control render={<Input />} />
</Field.Root>
</div>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</Layout>
);
}Constraints
- Dialog renders full-screen sheet below 1024px; centered max-w-md at 1024–1280px
- Route-driven variant requires both parent path and create/edit path to render the same component
onOpenChangemust navigate back — just callingsetOpen(false)leaves the URL broken- Use
<Form onFormSubmit>, never a bare<form onSubmit>— see Form state below - Cancel must be
type="button"; inside a<Form>an untyped<button>defaults tosubmit
Form state
Applies to every form/* pattern. Full detail in components.md → Forms.
form/composer follows all of this too, with one documented exception noted below: its body is controlled, because a composer reads the value during render to gate its submit and swap its placeholder.
Form+Fieldis the default stack. They wrap Base UI and ship with AppShell — no extra dependency.- Submit via
onFormSubmit(values). It fires only after validation passes. Do not hand-roll<form onSubmit>+new FormData(...)— that skips validation and server-error routing. onFormSubmitreads registeredField.Roots, not the DOM. So every control — includingSelect,Combobox, andAutocomplete— just needs wrapping in aField.Root name="…". They need nonameof their own and nouseState. Mirroring field values into React state is the most common thing to get wrong here. The exception is a value the component must read during render — a submit gate, a dependent placeholder, a live character count. That state is load-bearing, not mirrored; seeform/composer.Field.Controlis already a styled input. Write<Field.Control />, not<Field.Control render={<Input />} />.- Object items need
itemToStringValue. Items shaped{ value, label }submitvalueautomatically; any other object submits as a JSON string unless you supply it. - Server errors go through
Form'serrorsprop, keyed by fieldname— not a toast or a banner. They clear when the user edits the field. - React Hook Form is optional, consumer-installed, and only warranted for cross-field validation, field arrays, or a Zod resolver. It composes with
Field: drive the control from aControllerand spreadfieldStateontoField.Root.
Anti-patterns
- Nesting modals — opening a Dialog from inside another Dialog
- Modal containing a wizard — promote to a routed
form/wizard - Save closes the dialog but parent state is stale — wire refetch or optimistic update
- Building a routed Create/Edit page when the design didn't explicitly call for one — modal is the default
- Registering the create path as a separate top-level route — that unmounts the parent list
- Reaching for React Hook Form on a form this size —
onFormSubmitalready covers it - Holding a
Select/Comboboxvalue inuseStatejust to submit it —Field.Rootalready does - Surfacing API validation failures in a toast or banner instead of routing them via
errors