Back to Machine Coding
Machine Coding
easy
mid

How would you design a modal management system in React?

Imperative API (`modal.open(<Comp/>)`) backed by a React context store of open modals; renders via a single portal at the root; supports stacking, per-modal options (size, dismissible), focus trap, scroll lock, Esc + outside-click close, and returning a promise for confirm/cancel flows. Decouples 'who opens' from 'who renders'.

4 min read·~40 min to think through

A modal system answers the question: who renders the modal, and who can open it? A good system makes "open from anywhere" feel imperative without sacrificing React's declarative model.

1. The API you want

jsx
const modal = useModal();
modal.open(<ConfirmDialog message="Delete?" />, { size: "sm" });

// Promise-returning for confirm flows:
const ok = await modal.confirm({ title: "Delete?", message: "Cannot be undone" });
if (ok) await deleteItem();

Plus declarative <Modal open={...}> for cases where it really belongs in the tree.

2. The architecture

A context provider holds an array of open modals; a single <ModalRoot/> portals to <body> and renders them stacked. Components anywhere call modal.open(...).

jsx
const ModalCtx = createContext(null);

export function ModalProvider({ children }) {
  const [stack, setStack] = useState([]);

  const open = (content, options = {}) => {
    const id = crypto.randomUUID();
    let resolveFn;
    const promise = new Promise((r) => (resolveFn = r));

    const close = (result) => {
      setStack((s) => s.filter((m) => m.id !== id));
      resolveFn(result);
    };

    setStack((s) => [...s, { id, content, options, close }]);
    return { close, promise };
  };

  const confirm = ({ title, message }) =>
    open(<ConfirmDialog title={title} message={message} />).promise;

  return (
    <ModalCtx.Provider value={{ open, confirm }}>
      {children}
      <ModalRoot stack={stack} />
    </ModalCtx.Provider>
  );
}

3. ModalRoot — single portal, stacked

jsx
function ModalRoot({ stack }) {
  return createPortal(
    stack.map((m, i) => (
      <ModalShell key={m.id} options={m.options} isTop={i === stack.length - 1} onClose={m.close}>
        {m.content}
      </ModalShell>
    )),
    document.body
  );
}

Only the top modal listens for Escape; others stay open behind.

4. ModalShell — the accessibility & UX layer

  • Backdrop that catches outside clicks (configurable dismissible).
  • Escape to close (top of stack only).
  • Focus trap — focus moves into the modal on open; Tab cycles within; on close, focus returns to the previously focused element.
  • Scroll lock on <body> while at least one modal is open.
  • role="dialog" + aria-modal="true" + aria-labelledby on the dialog.
  • Animation in/out (Framer Motion or CSS).

5. Stacking — z-index and a11y

  • One portal, stacked children with increasing z-index.
  • Inert the underlying app from screen readers via aria-hidden or the inert attribute on the root while a modal is open.

6. Promise-returning confirm/alert

Make user choices ergonomic in event handlers:

jsx
async function handleDelete() {
  if (!(await modal.confirm({ title: "Delete?" }))) return;
  await api.delete(id);
}

The dialog calls close(true) on confirm, close(false) on cancel.

7. Edge cases

  • Route change while open — close all modals on route change unless flagged.
  • Modal triggered from inside another modal — stack works; just don't break focus trap (top one owns focus).
  • SSR — the portal can't render server-side without a DOM; gate with a mounted check.
  • Memory leaks — clean up event listeners and focus refs on unmount.

Interview framing

"A context provider owns a stack of open modals. Components call open(content, options) from anywhere — it adds to the stack and returns a promise that resolves when the modal closes. A single <ModalRoot> portals to <body> and renders the stack. Each modal shell handles backdrop, Escape (only the top), focus trap, scroll lock, and the dialog ARIA roles. The promise-returning confirm makes async event handlers ergonomic. Routes closing modals, SSR portal guards, and underlying-content inerting are the gotchas."

Follow-up questions

  • Why a single portal vs one per modal?
  • Why does only the top of the stack listen for Escape?
  • How does the promise-returning confirm pattern work?
  • How do you make the rest of the app inert for screen readers?

Common mistakes

  • Rendering each modal where it's opened — focus and stacking nightmares.
  • No focus trap.
  • Forgetting to return focus on close.
  • Esc closing every modal at once, not just the top.
  • Body scroll under the modal.

Performance considerations

  • Stack is tiny; perf is irrelevant. Avoid recreating handlers each render with useCallback if needed; lazy-load heavy modal content.

Edge cases

  • Route change with a modal open.
  • Nested modals from a modal.
  • SSR — no document.body.
  • Animation in-flight when force-closing.

Real-world examples

  • react-modal, Headless UI Dialog, Radix Dialog, Chakra UI Modal.
  • Linear's command palette + dialog stack.

Senior engineer discussion

Seniors decouple 'open' from 'render', centralize accessibility (focus, ARIA, Escape, scroll lock) in one shell, and provide both imperative (open) and declarative (`<Modal open>`) APIs — letting callers pick. Promise-returning confirm is the small touch that makes the whole API feel right.

Related questions