Skip to content

Repository files navigation

react-modal-stack

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.

React TypeScript Vite Zustand License: MIT


Why this exists

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.

Before: modal state in App re-renders everything below it. After: modal state in an external store, App never re-renders.

Features

  • 🚫 Zero re-renders of the host app — modal state lives in a Zustand store; only ModalManager subscribes 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 (see dist/assets/*.js after npm 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.

Quick start

git clone https://github.com/YOUR-USERNAME/react-modal-stack.git
cd react-modal-stack
npm install
npm run dev

Open 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 locally

Project structure

src/
├── 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

How it works

  1. State lives outside React. modalStore.ts is a plain Zustand store holding a stack: ModalEntry[]. Nothing about it depends on where in the tree it's read from.
  2. Opening a modal doesn't create a subscription. openModal() calls useModalStore.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.
  3. Exactly one component reacts. ModalManager is the only place that calls useModalStore((s) => s.stack). It's mounted next to <App /> in main.tsx, as a sibling — never as a parent — so a stack update can never cascade into the app tree.
  4. Nesting is just a longer array. A modal calling openModal('nextModal', {...}) from its own onClick appends another entry to the same stack. Closing one layer (closeModal(id)) filters that one entry out; everything else is untouched.
  5. The URL is a second, synced source of truth. useModalUrlSync writes the serialized stack to the query string with pushState (so each opened layer is a real history entry) and listens for popstate to rebuild the stack when the user hits back — with a ref-based guard to avoid feedback loops.

Adding your own modal

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.

How this fits alongside existing libraries

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 Shell component 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.

License

MIT — see LICENSE.

About

A stack-based modal architecture for React that never re-renders your app. Supports nested modals/bottom sheets, URL sync (back button, refresh, deep links), and lazy-loaded content — built with Zustand + Portals. Includes a live render counter to prove it.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages