Skip to content
← Writing

Your dialog state should be invisible

Open dialogs from anywhere, without prop plumbing or state pollution.

· 5 min read

You're deep in a feature. A dropdown action needs to open a dialog. The dialog lives two levels above in the React tree, so you lift state: add isDeleteDialogOpen to the parent, pass setIsDeleteDialogOpen() through the middle, wire it to the menu item, ship it.

Then there are twelve dialogs.

The problem isn't React state. It's the assumption that dialogs belong to their call sites. A delete confirmation doesn't care where it was triggered. It needs to mount, receive props, and close itself. The call site is incidental. So is the parent that happens to own the boolean.

My approach

Opening a dialog should be one call:

openDialog(DeleteTaskDialog, { task });

No boolean. No setter. No props threaded through unrelated components. The dialog appears, does its job, and closes.

For feature code, wrap that call in a namespaced object:

// anywhere in the feature
taskDialog.delete({ task });
taskDialog.create({ defaultTitle: 'Untitled' });

The call site doesn't know how taskDialog.delete mounts the component. It just calls it.

How the stack works

A dialog is a component rendered somewhere else. A single <DialogRenderer> at the app root owns that:

export function DialogRenderer() {
  const dialogs = useDialogs();
 
  return (
    <>
      {dialogs.map(({ id, component: Component, props }) => (
        <Component
          key={id}
          {...props}
          open
          setOpen={(open) => {
            if (!open) {
              closeDialog(id);
            }
          }}
        />
      ))}
    </>
  );
}

useDialogs() reads from a global store: a plain array of { id, component, props } entries. openDialog() pushes to it. closeDialog() removes by ID. That's the platform.

The store can be small. A module-level array with a useSyncExternalStore() subscriber is enough:

type DialogEntry<TProps> = {
  id: string;
  component: ComponentType<TProps & DialogProps>;
  props: TProps;
};
 
let dialogs: DialogEntry<unknown>[] = [];
const listeners = new Set<() => void>();
 
function notify() {
  listeners.forEach((l) => l());
}
 
export function openDialog<TProps>(
  component: ComponentType<TProps & DialogProps>,
  props: TProps,
) {
  dialogs = [...dialogs, { id: crypto.randomUUID(), component, props }];
  notify();
}
 
export function closeDialog(id: string) {
  dialogs = dialogs.filter((d) => d.id !== id);
  notify();
}
 
export function useDialogs() {
  return useSyncExternalStore(
    (callback) => {
      listeners.add(callback);
      return () => listeners.delete(callback);
    },
    () => dialogs,
  );
}

useSyncExternalStore() gives React a correct external subscription: no context, no provider, no stale closures.

Dialog components stay controlled

<DialogRenderer> always passes open={true} because a dialog only mounts while it is in the stack. Closing removes it from the array.

Individual dialog components stay controlled. They accept open and setOpen() and do not know they are in a stack:

type WithDialogProps<T = Record<string, unknown>> = T & {
  open: boolean;
  setOpen: (open: boolean) => void;
};
 
type DeleteTaskDialogProps = WithDialogProps<{
  task: Task;
}>;
 
export function DeleteTaskDialog({
  task,
  open,
  setOpen,
}: DeleteTaskDialogProps) {
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent>
        <p>Delete "{task.title}"?</p>
        <Button onClick={() => setOpen(false)}>Cancel</Button>
        <Button variant="destructive" onClick={handleDelete}>
          Delete
        </Button>
      </DialogContent>
    </Dialog>
  );
}

The platform owns mounting. The component owns rendering. Neither needs to know how the other works.

Defining namespaced actions with defineDialogActions()

openDialog() is the low-level API. For feature code, defineDialogActions() creates a typed, namespaced object colocated with the feature:

// tasks.dialog.ts
export const taskDialog = defineDialogActions({
  delete: TaskDialogDelete,
  create: TaskDialogCreate,
  rename: TaskDialogRename,
});

Each key maps a name to a dialog component. The implementation infers TProps from the component and exposes a typed function:

type DialogActions<
  TMap extends Record<
    string,
    ComponentType<DialogProps & Record<string, unknown>>
  >,
> = {
  [K in keyof TMap]: TMap[K] extends ComponentType<infer TProps>
    ? (props: Omit<TProps, keyof DialogProps>) => void
    : never;
};
 
export function defineDialogActions<
  TMap extends Record<
    string,
    ComponentType<DialogProps & Record<string, unknown>>
  >,
>(map: TMap): DialogActions<TMap> {
  return Object.fromEntries(
    Object.entries(map).map(([key, component]) => [
      key,
      (props: Record<string, unknown>) => openDialog(component, props),
    ]),
  ) as DialogActions<TMap>;
}

The result: taskDialog.delete({ task }) is fully typed. TypeScript errors if you pass the wrong props or forget a required one. The feature owns its {entity}.dialog.ts file, so it stays easy to find.

What this buys you

Call sites become one line, no matter where they live: menus, hooks, keyboard shortcuts. Dialogs can now open from places where prop threading would be painful: a useHotkeys() handler, a data-driven context menu, a WebSocket event.

The stack also handles multiple dialogs naturally. A dialog that opens a second dialog calls openDialog() again. Both live in the array, render, and close independently.

Because <DialogRenderer> is the single mount point, dialogs render near the top of the DOM, outside overflow-hidden containers and awkward stacking contexts.

When not to use this

Use the stack for dialogs that are semantically independent of their trigger: confirmations, detail editors, forms that float free of the content they came from.

Inline disclosures, tooltips, and popovers that are visually attached to an element belong at the call site. They are extensions of the trigger, not stack-level dialogs.


Shoutout to Sonner for making this pattern click. The toast API, toast.success("Saved") from anywhere, rendered by a single <Toaster>, is this model applied to notifications.