React Modal Dialog Pattern
Core Rule
Split every modal-like component into two layers:
- Container: owns the architectural boundary and open/close wiring.
- 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
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}
<DialogContent className='...'>
<ThingDialogContent initialValues={initialValues} />
</DialogContent>
</Dialog>
);
}
function ThingDialogContent({ initialValues, onConfirm }: ThingDialogProps) {
const form = useForm<ThingValues>({ defaultValues: { ...initialValues } });
return (
// body JSX
);
}
Caller side:
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:
<ThingDialog initialValues={initialValues} />
Canonical Drawer Shape
Use the same split for shadcn/vaul drawer implementations.
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}
<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
- Define the modal key in a per-feature
constants/modalKeys.ts when the component is app-level and externally opened.
- Build the container:
- Register/open the primitive.
- Render only primitive root and content wrapper.
- Mount the split content component inside the content wrapper.
- Build the content:
- Move all body JSX and all hooks into the content component.
- Keep transient UI state here so it resets on unmount.
- Mount the container once in the feature root.
- Open from any caller with
useModalActions(KEY).onOpen().
- 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.
1---2name: react-modal-dialog-pattern3description: 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.4---56# React Modal Dialog Pattern78## Core Rule910Split every modal-like component into two layers:11121. **Container**: owns the architectural boundary and open/close wiring.132. **Content**: owns the interactive body and all hooks/state that should exist only while the UI is open.1415This 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.1617## Layer 1: Container1819The container is the architecture-facing component. It should be boring.2021Responsibilities:22- Register or receive the primitive open state.23- Render only the primitive root and primitive content wrapper, such as `Dialog`, `DialogContent`, `Drawer`, `DrawerContent`, `Popover`, `PopoverContent`, `DropdownMenu`, or `DropdownMenuContent`.24- Pass stable data and callbacks into the content component.25- Keep callers focused on opening and closing the component, not managing internal state.2627For 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).2829Define the key once in a per-feature `constants/modalKeys.ts`. Import the constant from the container and every caller. Never inline modal key strings.3031## Layer 2: Content3233The content component is mounted inside the primitive content wrapper.3435Responsibilities:36- Own all body JSX.37- Own all hooks: `useForm`, `useFieldArray`, `useQuery`, `useMutation`, `useEffect`, `useWatch`, subscriptions, debounced effects, and local `useState`.38- 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.39- Keep expensive rerenders contained to the opened UI body instead of rerendering the container/root.4041Do 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.4243## Why4445- **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.46- **Avoid broad rerenders.** Updating a value inside the body should rerender the body, not the entire dialog/drawer container or parent feature container.47- **Avoid background effects.** Watchers, `useEffect`, and queries should not keep running while the overlay is closed.48- **Keep architecture clean.** External components care about opening and closing; the content component cares about interaction.4950## Canonical Dialog Shape5152```tsx53import { useModalRegister } from '@/hooks/useModal';54import { Dialog, DialogContent } from '@/components/ui/dialog';55import { THING_DIALOG_KEY } from '../constants/modalKeys';5657interface ThingDialogProps {58 initialValues?: Partial<ThingValues>;59 onConfirm: (values: ThingValues) => Promise<void>;60}6162export function ThingDialog({ initialValues, onConfirm }: ThingDialogProps) {63 const { isOpen, onClose } = useModalRegister(THING_DIALOG_KEY);6465 return (66 <Dialog open={isOpen} onOpenChange={onClose}>67 <DialogContent className='...'>68 <ThingDialogContent initialValues={initialValues} onConfirm={onConfirm} />69 </DialogContent>70 </Dialog>71 );72}7374function ThingDialogContent({ initialValues, onConfirm }: ThingDialogProps) {75 const form = useForm<ThingValues>({ defaultValues: { ...initialValues } });7677 return (78 // body JSX79 );80}81```8283Caller side:8485```tsx86import { useModalActions } from '@/hooks/useModal';87import { THING_DIALOG_KEY } from '../constants/modalKeys';8889const { onOpen: openThing } = useModalActions(THING_DIALOG_KEY);90```9192Mount the container once in the feature root:9394```tsx95<ThingDialog initialValues={initialValues} onConfirm={handleConfirm} />96```9798## Canonical Drawer Shape99100Use the same split for shadcn/vaul drawer implementations.101102```tsx103import { useModalRegister } from '@/hooks/useModal';104import { Drawer, DrawerContent } from '@/components/ui/drawer';105import { SETTINGS_DRAWER_KEY } from '../constants/modalKeys';106107export function SettingsDrawer({ resourceId }: SettingsDrawerProps) {108 const { isOpen, onClose } = useModalRegister(SETTINGS_DRAWER_KEY);109110 return (111 <Drawer direction='right' open={isOpen} onOpenChange={onClose}>112 <DrawerContent>113 <SettingsDrawerContent resourceId={resourceId} />114 </DrawerContent>115 </Drawer>116 );117}118119function SettingsDrawerContent({ resourceId }: SettingsDrawerProps) {120 const [activeTab, setActiveTab] = useState('profile');121122 return (123 // tabs, forms, secret-field visibility, queries, and body JSX124 );125}126```127128## Menus, Options, and Popovers129130For smaller primitives, still apply the same mental model:131132- The outer component wires `open`, `onOpenChange`, trigger, and primitive content wrapper.133- A split content component owns option state, search value, effects, and dynamic rows.134- 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.135136## Anti-Patterns137138Reject these in review:139140- `interface FooDialogProps { isOpen: boolean; onClose: () => void; ... }` for app-level dialogs/drawers where the modal registry should own open state.141- `useModalRegister(KEY)` in the parent container instead of the dialog/drawer container.142- `useForm`, `useQuery`, `useWatch`, `useEffect`, tab state, option state, or secret visibility state in the outer container.143- Drawer/menu/option components where changing a field rerenders the entire feature container.144- Content state intentionally relying on closed UI persistence without a product requirement.145- Inline string keys like `useModalRegister('foo-dialog')`; always import a constant.146- Multiple components calling `useModalRegister(KEY)` for the same key. One component registers; callers use `useModalActions(KEY)`.147148## Flow1491501. Define the modal key in a per-feature `constants/modalKeys.ts` when the component is app-level and externally opened.1512. Build the container:152 - Register/open the primitive.153 - Render only primitive root and content wrapper.154 - Mount the split content component inside the content wrapper.1553. Build the content:156 - Move all body JSX and all hooks into the content component.157 - Keep transient UI state here so it resets on unmount.1584. Mount the container once in the feature root.1595. Open from any caller with `useModalActions(KEY).onOpen()`.1606. Verify with your project's type-check and lint commands before finishing.161162## Reference Examples163164See `references/` for a self-contained implementation:165166- `references/hooks/useModal.ts`: the modal registry (`useModalRegister`, `useModalActions`, `useModalIsOpen`), built on `references/hooks/useDisclosure.ts` and `references/stores/modalAtom.ts` (Jotai backend).167- `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.168- `references/components/EditItemDialog.tsx`: dialog container plus split form content.169- `references/components/SettingsDrawer.tsx`: drawer container plus split settings content.170- `references/components/ItemListToolbar.tsx`: a caller that opens a modal by key.171- `references/constants/modalKeys.ts`: shared modal key constants.