---
title: "FormDialog and FormSheet"
description: "A TanStack form in a dialog (a create flow) or a side sheet (an editor), finished: give it the form, a title and the fields. Enter and Ctrl+Enter submit, the save shows and blocks dismissal, it closes once onSubmit resolves and stays open with the reason when it throws, and closing with changes asks first."
---

> Documentation Index
> Fetch the complete documentation index at: https://epidermis.getarbol.com/llms.txt
> Use this file to discover all available pages before exploring further.

# FormDialog and FormSheet

## Create flow in a dialog

The shortest real call: a form, a title, the fields. Submit it empty to see the field errors, with a name ending in “Test” to see a refusal, or with a name to save and close.

```tsx
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { Key } from 'react-aria-components';
import { Button, SelectItem } from '@arbol-ai/ui';
import { FormDialog, useAppForm } from '@arbol-ai/ui/form';

const roundTrip = async () => {
  // oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
  await new Promise((resolve) => {
    setTimeout(resolve, 900);
  });
};

const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Button
        onPress={() => {
          setIsOpen(true);
        }}
        variant="outline"
      >
        {label}
      </Button>
      {isOpen
        ? children(() => {
            setIsOpen(false);
          })
        : null}
    </>
  );
};

const NewContactExample = () => (
  <Opener label="New contact">{(close) => <NewContactDialog onClose={close} />}</Opener>
);

const NewContactDialog = ({ onClose }: { onClose: () => void }) => {
  const form = useAppForm({
    defaultValues: { name: '', phone: '', channel: null as Key | null },
    onSubmit: async () => {
      await roundTrip();
    },
    validators: {
      onSubmit: ({ value }) =>
        value.name.endsWith('Test') ? 'Test contacts are turned off for this clinic.' : undefined,
    },
  });
  return (
    <FormDialog form={form} onClose={onClose} submitLabel="Add contact" title="New contact">
      <form.AppField
        name="name"
        validators={{ onSubmit: ({ value }) => (value.trim() === '' ? 'Enter a name.' : undefined) }}
      >
        {(field) => <field.TextField isRequired label="Name" />}
      </form.AppField>
      <form.AppField
        name="phone"
        validators={{
          onSubmit: ({ value }) =>
            /^\+?[\d\s-]{7,}$/u.test(value) ? undefined : 'Enter a phone like +57 300 555 0101.',
        }}
      >
        {(field) => <field.TextField isRequired label="Phone" type="tel" />}
      </form.AppField>
      <form.AppField name="channel">
        {(field) => (
          <field.Select label="Preferred channel">
            <SelectItem id="whatsapp">WhatsApp</SelectItem>
            <SelectItem id="phone">Phone call</SelectItem>
          </field.Select>
        )}
      </form.AppField>
    </FormDialog>
  );
};

<NewContactExample />
```

## Editor in a sheet

Change the note, then press Esc to see the discard question. Delete, at the start of the footer, asks for the name first.

```tsx
import { TrashIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button, DeleteDialog, OverlayTrigger } from '@arbol-ai/ui';
import { FormSheet, useAppForm } from '@arbol-ai/ui/form';

const roundTrip = async () => {
  // oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
  await new Promise((resolve) => {
    setTimeout(resolve, 900);
  });
};

const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Button
        onPress={() => {
          setIsOpen(true);
        }}
        variant="outline"
      >
        {label}
      </Button>
      {isOpen
        ? children(() => {
            setIsOpen(false);
          })
        : null}
    </>
  );
};

const ContactEditorExample = () => (
  <Opener label="Edit Ana Torres">{(close) => <ContactEditor onClose={close} />}</Opener>
);

const ContactEditor = ({ onClose }: { onClose: () => void }) => {
  const form = useAppForm({
    defaultValues: { note: 'Prefers mornings.', receivesCampaigns: true },
    onSubmit: async () => {
      await roundTrip();
    },
  });
  return (
    <FormSheet
      description="Changes apply to every campaign."
      footerStart={
        <OverlayTrigger>
          <Button aria-label="Delete contact" size="icon" variant="ghost">
            <TrashIcon aria-hidden />
          </Button>
          <DeleteDialog
            body="Their conversations stay; they stop receiving campaigns."
            name="Ana Torres"
            onDelete={(close) => {
              close();
              onClose();
            }}
          />
        </OverlayTrigger>
      }
      form={form}
      onClose={onClose}
      size="md"
      submitLabel="Save changes"
      title="Ana Torres"
    >
      <form.AppField name="note">
        {(field) => <field.TextField description="Only your team sees it." label="Note" multiline />}
      </form.AppField>
      <form.AppField name="receivesCampaigns">
        {(field) => <field.Switch>Receives campaigns</field.Switch>}
      </form.AppField>
    </FormSheet>
  );
};

<ContactEditorExample />
```

## Two steps

closeOnSubmit=&#123;false&#125; on the first step and onBack on the second: Continue moves on, Back returns, and the last step closes once saved.

```tsx
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@arbol-ai/ui';
import { FormDialog, useAppForm } from '@arbol-ai/ui/form';

const roundTrip = async () => {
  // oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
  await new Promise((resolve) => {
    setTimeout(resolve, 900);
  });
};

const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <Button
        onPress={() => {
          setIsOpen(true);
        }}
        variant="outline"
      >
        {label}
      </Button>
      {isOpen
        ? children(() => {
            setIsOpen(false);
          })
        : null}
    </>
  );
};

const MergeExample = () => (
  <Opener label="Merge contacts">{(close) => <MergeDialog onClose={close} />}</Opener>
);

const MergeDialog = ({ onClose }: { onClose: () => void }) => {
  const [step, setStep] = useState<'pick' | 'confirm'>('pick');
  const form = useAppForm({
    defaultValues: { duplicate: '' },
    onSubmit: async () => {
      if (step === 'pick') {
        setStep('confirm');
        return;
      }
      await roundTrip();
    },
  });
  return (
    <FormDialog
      closeOnSubmit={step === 'confirm'}
      description={
        step === 'pick'
          ? 'Who is the same person as Ana Torres?'
          : 'Their conversations, phones and facts move to Ana Torres.'
      }
      form={form}
      onBack={
        step === 'confirm'
          ? () => {
              setStep('pick');
            }
          : undefined
      }
      onClose={onClose}
      submitLabel={step === 'pick' ? 'Continue' : 'Merge contacts'}
      title="Merge contacts"
    >
      {step === 'pick' ? (
        <form.AppField
          name="duplicate"
          validators={{ onSubmit: ({ value }) => (value.trim() === '' ? 'Enter who to merge.' : undefined) }}
        >
          {(field) => <field.TextField description="Their name or phone." label="Duplicate contact" />}
        </form.AppField>
      ) : (
        <p className="text-sm">The duplicate is removed once everything moved.</p>
      )}
    </FormDialog>
  );
};

<MergeExample />
```

## Source

The examples above are [`src/form/form-dialog.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/form/form-dialog.examples.tsx), which the library's tests render too.

Source: https://epidermis.getarbol.com/components/blocks/form-dialog/index.mdx
