---
url: https://docs.tailor.tech/app-shell/patterns/form-modal.md
description: >-
  Default form pattern for Create/Edit — keeps user in context on the parent
  screen
---

# 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.Root`s, 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`
