# React Modal Dialog Pattern

> Use when building, refactoring, or reviewing modal-like UI: Dialog, Drawer, Sheet, Popover, Option panel, Select menu, dropdown menu, command menu, or any component with open/close state. Also use for new `*Dialog.tsx`, `*Drawer.tsx`, menu/option components, shadcn/Radix/vaul primitives, or reviews where open state, persisted state, or broad rerenders are a concern.

- Skill: `tianyili/react-modal-dialog-pattern` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add tianyili/react-modal-dialog-pattern`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tianyili/react-modal-dialog-pattern/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tianyili (https://skillmd.com/u/tianyili)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tianyili/react-modal-dialog-pattern

---


# React Modal Dialog Pattern

## Core Rule

Split every modal-like component into two layers:

1. **Container**: owns the architectural boundary and open/close wiring.
2. **Content**: owns the interactive body and all hooks/state that should exist only while the UI is open.

This applies beyond dialogs. Use the same split for drawer, sheet, popover, option panel, dropdown menu, command menu, and any collapsible/overlay primitive whose content can hold form state, selected options, watchers, subscriptions, or effects.

## Layer 1: Container

The container is the architecture-facing component. It should be boring.

Responsibilities:
- Register or receive the primitive open state.
- Render only the primitive root and primitive content wrapper, such as `Dialog`, `DialogContent`, `Drawer`, `DrawerContent`, `Popover`, `PopoverContent`, `DropdownMenu`, or `DropdownMenuContent`.
- Pass stable data and callbacks into the content component.
- Keep callers focused on opening and closing the component, not managing internal state.

For app-level dialogs/drawers, the container calls `useModalRegister(KEY)` itself. The parent does not hold `isOpen`/`onClose` in `useState` and does not pass them down. Callers open or close it with `useModalActions(KEY)`. See `references/hooks/useModal.ts` for a reference registry implementation (Jotai backend; `references/hooks/useModal.zustand.ts` is the same API on Zustand).

Define the key once in a per-feature `constants/modalKeys.ts`. Import the constant from the container and every caller. Never inline modal key strings.

## Layer 2: Content

The content component is mounted inside the primitive content wrapper.

Responsibilities:
- Own all body JSX.
- Own all hooks: `useForm`, `useFieldArray`, `useQuery`, `useMutation`, `useEffect`, `useWatch`, subscriptions, debounced effects, and local `useState`.
- Own ephemeral state that should reset on close or remount, such as selected tab, visible secret key, search value, selected menu option, dirty form state, and temporary validation state.
- Keep expensive rerenders contained to the opened UI body instead of rerendering the container/root.

Do not put meaningful hooks in the outer container. The container should not keep form objects, watchers, API queries, tab state, option state, or secret visibility state.

## Why

- **Avoid persisted state bugs.** Closed UI should not keep stale form values, selected options, visible secret keys, or old initial values unless the product explicitly requires persistence.
- **Avoid broad rerenders.** Updating a value inside the body should rerender the body, not the entire dialog/drawer container or parent feature container.
- **Avoid background effects.** Watchers, `useEffect`, and queries should not keep running while the overlay is closed.
- **Keep architecture clean.** External components care about opening and closing; the content component cares about interaction.

## Canonical Dialog Shape

```tsx
import { useModalRegister } from '@/hooks/useModal';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { THING_DIALOG_KEY } from '../constants/modalKeys';

interface ThingDialogProps {
  initialValues?: Partial<ThingValues>;
  onConfirm: (values: ThingValues) => Promise<void>;
}

export function ThingDialog({ initialValues, onConfirm }: ThingDialogProps) {
  const { isOpen, onClose } = useModalRegister(THING_DIALOG_KEY);

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className='...'>
        <ThingDialogContent initialValues={initialValues} onConfirm={onConfirm} />
      </DialogContent>
    </Dialog>
  );
}

function ThingDialogContent({ initialValues, onConfirm }: ThingDialogProps) {
  const form = useForm<ThingValues>({ defaultValues: { ...initialValues } });

  return (
    // body JSX
  );
}
```

Caller side:

```tsx
import { useModalActions } from '@/hooks/useModal';
import { THING_DIALOG_KEY } from '../constants/modalKeys';

const { onOpen: openThing } = useModalActions(THING_DIALOG_KEY);
```

Mount the container once in the feature root:

```tsx
<ThingDialog initialValues={initialValues} onConfirm={handleConfirm} />
```

## Canonical Drawer Shape

Use the same split for shadcn/vaul drawer implementations.

```tsx
import { useModalRegister } from '@/hooks/useModal';
import { Drawer, DrawerContent } from '@/components/ui/drawer';
import { SETTINGS_DRAWER_KEY } from '../constants/modalKeys';

export function SettingsDrawer({ resourceId }: SettingsDrawerProps) {
  const { isOpen, onClose } = useModalRegister(SETTINGS_DRAWER_KEY);

  return (
    <Drawer direction='right' open={isOpen} onOpenChange={onClose}>
      <DrawerContent>
        <SettingsDrawerContent resourceId={resourceId} />
      </DrawerContent>
    </Drawer>
  );
}

function SettingsDrawerContent({ resourceId }: SettingsDrawerProps) {
  const [activeTab, setActiveTab] = useState('profile');

  return (
    // tabs, forms, secret-field visibility, queries, and body JSX
  );
}
```

## Menus, Options, and Popovers

For smaller primitives, still apply the same mental model:

- The outer component wires `open`, `onOpenChange`, trigger, and primitive content wrapper.
- A split content component owns option state, search value, effects, and dynamic rows.
- If the content is trivial and stateless, the split can be omitted. The moment it has hooks, watches values, performs queries, or mutates options, split it.

## Anti-Patterns

Reject these in review:

- `interface FooDialogProps { isOpen: boolean; onClose: () => void; ... }` for app-level dialogs/drawers where the modal registry should own open state.
- `useModalRegister(KEY)` in the parent container instead of the dialog/drawer container.
- `useForm`, `useQuery`, `useWatch`, `useEffect`, tab state, option state, or secret visibility state in the outer container.
- Drawer/menu/option components where changing a field rerenders the entire feature container.
- Content state intentionally relying on closed UI persistence without a product requirement.
- Inline string keys like `useModalRegister('foo-dialog')`; always import a constant.
- Multiple components calling `useModalRegister(KEY)` for the same key. One component registers; callers use `useModalActions(KEY)`.

## Flow

1. Define the modal key in a per-feature `constants/modalKeys.ts` when the component is app-level and externally opened.
2. Build the container:
   - Register/open the primitive.
   - Render only primitive root and content wrapper.
   - Mount the split content component inside the content wrapper.
3. Build the content:
   - Move all body JSX and all hooks into the content component.
   - Keep transient UI state here so it resets on unmount.
4. Mount the container once in the feature root.
5. Open from any caller with `useModalActions(KEY).onOpen()`.
6. Verify with your project's type-check and lint commands before finishing.

## Reference Examples

See `references/` for a self-contained implementation:

- `references/hooks/useModal.ts`: the modal registry (`useModalRegister`, `useModalActions`, `useModalIsOpen`), built on `references/hooks/useDisclosure.ts` and `references/stores/modalAtom.ts` (Jotai backend).
- `references/hooks/useModal.zustand.ts` + `references/stores/modalStore.zustand.ts`: the same hook API on a Zustand store that owns the open flags directly (no registration step) — for projects using Zustand, swap only the import path.
- `references/components/EditItemDialog.tsx`: dialog container plus split form content.
- `references/components/SettingsDrawer.tsx`: drawer container plus split settings content.
- `references/components/ItemListToolbar.tsx`: a caller that opens a modal by key.
- `references/constants/modalKeys.ts`: shared modal key constants.

