Nested React modals & bottom sheets that never re-render your app.
A stack-based modal architecture built with Zustand and Portals: state lives outside the component tree, nesting is just pushing onto an array, and the URL stays in sync with what's open — back button, refresh, and deep links all just work.
The usual way to build a modal — useState in a parent component — re-renders everything under that parent on every open and close. On a simple page you won't notice. On a page with a data table, a chart, or a long list, it's a visible stutter every time someone taps "Share."
This repo isolates modal state in an external store that only one component subscribes to, so opening a three-layer-deep stack of nested modals costs the app tree nothing.
- 🚫 Zero re-renders of the host app — modal state lives in a Zustand store; only
ModalManagersubscribes to it, and it's mounted as a sibling of<App />, not an ancestor. - 🪆 Real nesting, no special-casing — modal state is a stack. A modal opening another modal is just
push()onto the same array. Close one layer without touching the ones underneath it. - 🔗 URL-synced — the open stack is reflected in the query string (
?modals=share.42|comments.42). Refresh, share a link, or hit the back button and it does the right thing, one layer at a time. - ⚡ Lazy-loaded content — each modal's content is
React.lazy()-split, so a rarely opened modal doesn't bloat your initial bundle. Confirmed with a real production build (seedist/assets/*.jsafternpm run build). - 📊 Provable, not just claimed — a live render counter on the page and on every modal shows exactly what re-renders and what doesn't.
- 🧱 Two shell types out of the box — a centered modal and a bottom sheet, both
memo-ized and reusable for your own content.
git clone https://github.com/YOUR-USERNAME/react-modal-stack.git
cd react-modal-stack
npm install
npm run devOpen the app, click "Share" on any product, then "View comments" from inside it, then "Reply" from inside that. Three layers deep — watch the page's render counter stay flat the whole time.
npm run build # verify each modal is code-split into its own chunk
npm run preview # serve the production build locallysrc/
├── App.tsx # Demo page: product list + a render counter
├── main.tsx # Mounts App, ModalManager, and ModalUrlSync as siblings
└── modal/
├── modalStore.ts # Zustand store — the modal stack, nothing else
├── modalActions.ts # openModal / closeModal / closeAllModals
├── modalRegistry.tsx # key → lazy component, shell kind, URL params
├── ModalManager.tsx # The one component that subscribes to the stack
├── useModalUrlSync.ts # Two-way sync between the stack and the URL
├── useRenderCount.ts # Demo-only: counts renders so you can see it work
├── shells/
│ ├── ModalShell.tsx # Centered modal shell
│ └── BottomSheetShell.tsx # Bottom sheet shell
└── examples/
├── ShareModal.tsx # Layer 1
├── CommentSheet.tsx # Layer 2 — opened from inside ShareModal
└── ReplyModal.tsx # Layer 3 — opened from inside CommentSheet
- State lives outside React.
modalStore.tsis a plain Zustand store holding astack: ModalEntry[]. Nothing about it depends on where in the tree it's read from. - Opening a modal doesn't create a subscription.
openModal()callsuseModalStore.getState().push(...)— not the hook — so calling it from any click handler, anywhere, never causes an unrelated re-render as a side effect of the call itself. - Exactly one component reacts.
ModalManageris the only place that callsuseModalStore((s) => s.stack). It's mounted next to<App />inmain.tsx, as a sibling — never as a parent — so a stack update can never cascade into the app tree. - Nesting is just a longer array. A modal calling
openModal('nextModal', {...})from its ownonClickappends another entry to the same stack. Closing one layer (closeModal(id)) filters that one entry out; everything else is untouched. - The URL is a second, synced source of truth.
useModalUrlSyncwrites the serialized stack to the query string withpushState(so each opened layer is a real history entry) and listens forpopstateto rebuild the stack when the user hits back — with a ref-based guard to avoid feedback loops.
1. Write the content component — it receives your props plus onClose:
// src/modal/examples/MyModal.tsx
export default function MyModal({ someId, onClose }: { someId: string; onClose: () => void }) {
return (
<div className="modal-body">
<h2>My modal — #{someId}</h2>
<button className="btn" onClick={onClose}>Close</button>
</div>
);
}2. Register it:
// src/modal/modalRegistry.tsx
myModal: {
component: lazy(() => import('./examples/MyModal')),
kind: 'modal', // or 'sheet' for a bottom sheet
paramNames: ['someId'],
fromParams: (p) => ({ someId: p.someId }),
},3. Open it from anywhere — including from inside another modal, for free nesting:
import { openModal } from './modal/modalActions';
openModal('myModal', { someId: '123' });That's it — no new state, no new component wiring, no special nested-modal logic.
This isn't a replacement for your modal UI library — it's the state layer underneath it:
- Radix UI Dialog / Vaul handle accessibility and gesture mechanics (focus trapping, drag-to-dismiss) excellently. Swap them in as your
Shellcomponent and keep the store and stack underneath. - NiceModal solves imperative modal calls, but not URL sync or a real back-button story — this is closer to what you'd build on top of that idea.
- Next.js Intercepting/Parallel Routes solve URL sync at the framework level if you're already on the App Router. This pattern is the version of that idea for apps that aren't.
MIT — see LICENSE.
