Build a Modal Management system
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'.
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
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(...).
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
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-labelledbyon 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-hiddenor theinertattribute on the root while a modal is open.
6. Promise-returning confirm/alert
Make user choices ergonomic in event handlers:
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.