Skip to content
View as Markdown

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
  • onOpenChange must navigate back — just calling setOpen(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 to submit

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 + Field is 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.
  • onFormSubmit reads registered Field.Roots, not the DOM. So every control — including Select, Combobox, and Autocomplete — just needs wrapping in a Field.Root name="…". They need no name of their own and no useState. 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; see form/composer.
  • Field.Control is already a styled input. Write <Field.Control />, not <Field.Control render={<Input />} />.
  • Object items need itemToStringValue. Items shaped { value, label } submit value automatically; any other object submits as a JSON string unless you supply it.
  • Server errors go through Form's errors prop, keyed by field name — 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 a Controller and spread fieldState onto Field.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 — onFormSubmit already covers it
  • Holding a Select/Combobox value in useState just to submit it — Field.Root already does
  • Surfacing API validation failures in a toast or banner instead of routing them via errors