This article is published in English.
React Modals Without Re-rendering the Page: Stack Store and Portal Manager
Keep modal state in a Zustand stack outside the tree, render with a sibling ModalManager and portals, and sync nested dialogs to the URL with pushState.
TL;DR — Keep modal state out of the React component tree. Store it as a stack in an external store (Zustand here), render through one dedicated
ModalManagerthat mounts as a sibling of the app (not an ancestor), and load modal bodies through a portal withlazy(). Opening, closing, or nesting dialogs then never re-renders the page underneath; nesting is just pushing onto an array; and the URL stays aligned viapushState/popstate.
A common first modal system in React looks like this:
function App() {
const [modal, setModal] = useState(null);
// …rest of the app tree lives here
}
It works until it does not. Once modal state lives on an ancestor of the page, every open or close re-renders that whole subtree. On a light screen nobody notices. On charts, virtualized lists, or other heavy UI, tapping “Share” can stutter.
Add two more requirements—dialogs that open other dialogs (share → comments → reply) and a URL that mirrors what is open (refresh, back, deep links)—and a lone useState stops being merely wasteful and becomes hard to reason about.
The architecture below isolates those concerns.
The core idea: take the modal state out of the render tree
The re-render tax is about where state lives. Modal flags owned by an ancestor force that ancestor’s subtree to update on every change.
// modalStore.ts
import { create } from 'zustand';
export interface ModalEntry {
id: string;
key: string;
props?: Record<string, unknown>;
urlParams?: Record<string, string>;
}
interface ModalState {
stack: ModalEntry[];
push: (entry: Omit<ModalEntry, 'id'> & { id?: string }) => string;
pop: () => void;
popById: (id: string) => void;
closeAll: () => void;
setStack: (stack: ModalEntry[]) => void;
}
let counter = 0;
const nextId = () => `modal_${Date.now()}_${counter++}`;
export const useModalStore = create<ModalState>((set, get) => ({
stack: [],
push: (entry) => {
const id = entry.id ?? nextId();
set((s) => ({ stack: [...s.stack, { ...entry, id }] }));
return id;
},
pop: () => set((s) => ({ stack: s.stack.slice(0, -1) })),
popById: (id) => set((s) => ({ stack: s.stack.filter((m) => m.id !== id) })),
closeAll: () => set({ stack: [] }),
setStack: (stack) => set({ stack }),
}));
Two details matter. First, state is a stack, not a single slot—that is what makes nesting free. Second, this is a Zustand store, not React Context. Context notifies every consumer; Zustand lets a component select a slice so only that subscriber re-renders. For a cross-cutting “what is open?” concern, that difference is the point.
Opening a modal shouldn’t touch React state directly
Open and close helpers read and write the store through getState(), not through useModalStore():
// modalActions.ts
export function openModal(key: string, params: Record<string, string>) {
const meta = modalRegistry[key];
if (!meta) return;
useModalStore.getState().push({
key,
urlParams: params,
props: meta.fromParams(params),
});
}
export function closeModal(id: string) {
useModalStore.getState().popById(id);
}
Because openModal never calls the hook, invoking it does not create a store selection and does not re-render anything by itself—only the store update does, and only components that opted into the store feel it. Call openModal(...) from any click handler—including from inside another modal—and the only reactor is the component whose job is to react.
The one component that’s allowed to care
// ModalManager.tsx
export function ModalManager() {
const stack = useModalStore((s) => s.stack);
const root = usePortalRoot('modal-root');
if (!root || stack.length === 0) return null;
return createPortal(
<>
{stack.map((entry, index) => {
const meta = modalRegistry[entry.key];
if (!meta) return null;
const Component = meta.component;
const isTop = index === stack.length - 1;
const Shell = meta.kind === 'sheet' ? BottomSheetShell : ModalShell;
return (
<Shell key={entry.id} depth={index} isTop={isTop} onClose={() => closeModal(entry.id)}>
<Suspense fallback={<div className="modal-loading">Loading…</div>}>
<Component {...entry.props} onClose={() => closeModal(entry.id)} />
</Suspense>
</Shell>
);
})}
</>,
root
);
}
ModalManager mounts once beside <App />, not inside it:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ModalManager />
<ModalUrlSync />
</StrictMode>,
);
That sibling relationship carries the design. If ModalManager wrapped <App />, every stack change would re-render the manager and then its children—including the app. As siblings under one root, App never hears the update.
Nested modals are just a longer array
Once state is a stack, “modal inside a modal” is not a special case—it is the default behavior of push.
A share dialog can open a comments sheet, which can open a reply dialog; each appends another entry:
// Inside ShareModal
<button onClick={() => openModal('comments', { productId })}>
View comments
</button>
// Inside CommentSheet
<button onClick={() => openModal('reply', { commentId })}>
Reply
</button>
Each layer gets its own depth (for z-index) and closes by its own id. Closing reply leaves comments and share intact. No recursive modal component and no bespoke state machine—just an array with three items.
Keeping the shell dumb and memoized
The modal shell—overlay, card, animation, Escape handling—stays separate from content and wraps in React.memo:
export const ModalShell = memo(function ModalShell({ depth, isTop, onClose, children }) {
useEffect(() => {
if (!isTop) return; // only the topmost modal reacts to Escape
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isTop, onClose]);
return (
<div className="modal-overlay" style={{ zIndex: 1000 + depth }} onMouseDown={/* close on backdrop click */}>
<div className="modal-card" role="dialog" aria-modal="true">
<button className="modal-close" onClick={onClose} aria-label="Close">×</button>
{children}
</div>
</div>
);
});
Only the topmost shell listens for Escape; otherwise one keypress would try to dismiss every layer. Because the shell is content-agnostic, each body loads with lazy() per registry entry so a rare “reply” dialog does not bloat the initial bundle:
export const modalRegistry: Record<string, ModalRegistryItem> = {
share: { component: lazy(() => import('./examples/ShareModal')), kind: 'modal', ... },
comments: { component: lazy(() => import('./examples/CommentSheet')), kind: 'sheet', ... },
reply: { component: lazy(() => import('./examples/ReplyModal')), kind: 'modal', ... },
};
export const modalRegistry: Record<string, ModalRegistryItem> = {
share: { component: lazy(() => import('./examples/ShareModal')), kind: 'modal', ... },
comments: { component: lazy(() => import('./examples/CommentSheet')), kind: 'sheet', ... },
reply: { component: lazy(() => import('./examples/ReplyModal')), kind: 'modal', ... },
};
A production build confirms each modal becomes its own chunk, fetched only when that entry opens.
Making the URL tell the truth
Keeping the address bar aligned with the stack is a sync problem between the Zustand store and window.location. Get it wrong and you loop forever or make Back leave the page instead of popping one dialog.
A boolean ref marks “this change came from the URL, do not write it back”:
export function useModalUrlSync() {
const setStack = useModalStore((s) => s.setStack);
const stack = useModalStore((s) => s.stack);
const syncingFromUrl = useRef(false);
// stack -> URL
useEffect(() => {
if (syncingFromUrl.current) { syncingFromUrl.current = false; return; }
const serialized = serializeStack(stack);
const params = new URLSearchParams(window.location.search);
serialized ? params.set('modals', serialized) : params.delete('modals');
window.history.pushState({ modals: serialized }, '', `${window.location.pathname}?${params}`);
}, [stack]);
// URL -> stack (back/forward button)
useEffect(() => {
const onPopState = () => {
syncingFromUrl.current = true;
const raw = new URLSearchParams(window.location.search).get('modals') ?? '';
setStack(deserializeStack(raw));
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [setStack]);
}
Prefer pushState over replaceState: each open adds real history so Back closes one layer at a time—what users expect from three nested dialogs.
Put differently: treat modal orchestration as infrastructure, not as UI state owned by the screen. Screens keep their own data fetching and local form state. Infrastructure decides which overlays exist, in which order, and how the address bar mirrors that stack. That separation is what keeps expensive leaves idle while dialogs open and close.
When nesting is required, prefer explicit openModal calls from within content over boolean flags threaded through parents. Flags create coupling: every parent must know about every child dialog. A registry key plus params keeps the parent ignorant of the child’s internals and lets the manager own stacking order and history entries.
How this fits alongside existing libraries
This pattern does not replace the modal ecosystem; it fixes state placement. Overlaps with common tools:
- Radix UI
Dialogand Vaul excel at accessibility and gestures—focus traps, scroll lock, drag-to-dismiss. They are not opinionated about where “is open” lives, so either can become theShellhere. Store, portal, and stack sit underneath, not instead of them. - NiceModal (
@ebay/nice-modal-react) targets imperative show/hide (NiceModal.show(MyModal)) without boolean toggles. It fits when URL sync and deep nesting are unnecessary. The stack-and-store design is closer to NiceModal ergonomics plus a real Back story. - Next.js intercepting / parallel routes solve URL sync at the framework layer: a modal is a route segment over the page. On App Router that is often more idiomatic than hand-rolled
pushState. The store approach is the same idea for Vite/CRA React or older Pages Router apps without native intercepting routes.
If a library already handles chrome, keep its primitives as shells and retain the store plus ModalManager for isolation and nesting.
Is any of this required? Top-level useState in App is fewer lines and fine for many products. Be specific about what the external stack buys—and why “fewer re-renders” is not vague marketing.
Reconciliation cost scales with the subtree, not with how small the state change was. When a component re-renders, React walks non-memoized descendants even if the DOM diff is tiny. Flipping modalOpen in App is not “just showing a dialog”; it re-runs every function between App and the leaves, recomputes derivatives, rechecks useMemos, and re-fires effects whose deps changed identity. On a light page it is invisible. On a table, chart, rich editor, or long virtualized list, it is the gap between an instant pop and a one- or two-frame stutter.
Location sets blast radius, not payload size. A boolean and a five-item stack cost similar memory; the cost is who gets notified. Moving modal state into an external store that only ModalManager reads shrinks “a modal opened” from the whole page to one dedicated component. Nothing else can notice unless it selected that slice.
Context is not the fix, even when it looks like one. Context removes prop drilling; it does not stop re-renders. Every useContext consumer updates when the value changes, even if they ignore the field that changed. A root <ModalProvider> recreates the original blast radius with a different API. Selector-based stores (Zustand, Jotai, Redux selectors) fix it by letting components watch a slice.
Modals open at the worst moment to pay that tax. Share sheets, comment threads, and confirms follow a tap and should feel instant. A dropped frame that would hide on a background timer is obvious when it is the direct reply to a click.
You can measure instead of trusting claims. A demo can put a render counter on the page and on each modal: open, nest, close, and watch the page counter stay flat while each modal increments alone. React DevTools Profiler shows the same story—work inside the portal, not inside the sibling app tree.
Not every dialog needs this exact shape. A static confirm with no nesting on a cheap page will not repay the structure. It matters when the page under the modal is expensive to re-render, when nesting is real, or when open state must survive refresh via the URL. Then skipping isolation is not theoretical: it is a full-page re-render on every open and close for every user.
If a team already standardized on Radix or Vaul for a11y, adopt those shells first and only introduce the store when profilers show page-level work on open/close, or when product requires nested flows with Back-button fidelity. Premature infrastructure is real; so is paying a full-tree render on every Share tap once the dashboard is busy.
What you get from all of this
- Opening or closing any modal at any depth never re-renders the page—only the modal layer reads modal state.
- Nesting is structural: push/pop on an array, not a special case.
- Each modal body is code-split and loaded on demand.
- The URL stays a shareable, Back-friendly picture of what is open, including multiple layers.
None of the pieces are exotic—an external store, a portal, lazy loading, and pushState sync are everyday tools. The interesting rule is architectural: whatever decides what is open must never be an ancestor of what should not care.
A full Vite + React + TypeScript + Zustand demo with three nested modals and a live render counter is on GitHub: react-modal-stack. After npm install && npm run dev, walk Share → View comments → Reply and confirm the page behind never re-renders.