Skip to main content

Managing modals in React

If you have worked with React long enough, you have probably hit the modal problem. You need a confirmation dialog here, a details panel there, maybe a multi-step wizard somewhere else. Each one needs to open, close, and sometimes pass data around. It sounds simple until you have five modals, three of them nested, and your state is scattered across the tree like furniture parts without an instruction sheet.

There is no single blessed pattern for this. The ecosystem offers several directions, each with trade-offs worth understanding before you commit.

One context per modal

The most common starting point. You create a React context for each modal, wrap the relevant part of the tree in a provider, and expose open/close functions through a hook.

It works. For one or two modals it is perfectly fine. But it does not scale well. Every new modal means a new context, a new provider, and more nesting. Your component tree starts looking like a stack of shipping boxes, each one wrapped inside the next. Worse, if you need to open a modal from deep inside the tree or from a place that is not wrapped by the right provider, you are stuck.

A single modal context

A step forward. Instead of one context per modal, you create a single “modal manager” context that keeps track of all active modals. Components register themselves, and opening or closing a modal goes through one central hook.

This cleans up the nesting problem and gives you a single point of control. The downside is that the context still lives inside the React tree. Every state change triggers a re-render of everything subscribed to that context. You can optimize with memoization, but you are working against the grain. The provider must also sit high enough in the tree to be accessible everywhere, which makes lazy loading and code splitting harder than it should be.

Third-party state management

Libraries like Zustand, Jotai, or Redux can manage modal state outside the React tree. You define a store, track which modals are open, and subscribe from wherever you need.

This approach works well. It is battle-tested, flexible, and avoids the re-render problem. The trade-off is that you are pulling in a general-purpose state management tool to solve a fairly specific problem. If you already use one of these libraries in your project, it makes sense. If you do not, adding one just for modals feels like buying a power drill to hang a picture frame.

An external store built for the job

This is the approach I ended up using, and I think it hits the right balance.

Modern React gives us useSyncExternalStore(read more) - a hook designed to subscribe to external data sources. It is stable, it is part of React itself, and it lets you keep state outside the component tree while still triggering re-renders when that state changes. No providers, no context wrappers, no extra dependencies.

The idea is straightforward: create a lightweight store that holds a stack of active modals, and let React components subscribe to it. Opening or closing a modal is just a function call. It works from inside components, from event handlers, from utility modules - anywhere. The store does not care about the React tree. React just reads from it and renders what it finds.

I packaged this into a small library called @zemd/react-modals. Here is how it works in practice.

You place a ModalRoot component somewhere near the root of your app. It renders active modals into a portal:

import { ModalRoot } from "@zemd/react-modals";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <ModalRoot />
      </body>
    </html>
  );
}

Then you define a modal as a regular React component. Nothing special, just a component that receives props and renders content:

import { useModalContext } from "@zemd/react-modals";

const AlertModal = ({ title, message }) => {
  const { close } = useModalContext();
  const dialogRef = useRef(null);

  useEffect(() => {
    dialogRef.current?.showModal();
  }, []);

  return (
    <dialog ref={dialogRef} onCancel={close}>
      <h2>{title}</h2>
      <p>{message}</p>
      <button onClick={close}>OK</button>
    </dialog>
  );
};

You register it with createModal and get back a controller with open and close methods:

import { createModal } from "@zemd/react-modals";

const alertModal = createModal({ component: AlertModal });

// Open from anywhere - no hooks, no context needed
alertModal.open({ title: "Hello", message: "This is a modal." });

// Close it
alertModal.close();

Lazy loading works too. You can split modal code into separate chunks and load them only when needed:

const confirmModal = createModal({
  lazy: () =>
    import("./ConfirmModal").then((m) => ({ default: m.ConfirmModal })),
});

Under the hood, the store is a plain object with a stack, a set of listeners, and a frozen snapshot that React reads via useSyncExternalStore. When you call open, the store pushes an entry onto the stack, notifies subscribers, and React re-renders ModalRoot. When you call close, it pops the entry off. The store also supports lifecycle callbacks (onOpen, onClose), a configurable stack size limit, and the option to create multiple independent stores if your app needs separate modal stacks.

The full implementation is around 200 lines of TypeScript with zero dependencies. It is SSR-friendly - the server snapshot returns an empty array, so nothing renders during server-side rendering. It works with React 19 and Next.js out of the box.

Why this matters

Modal management is one of those problems that feels trivial until it is not. The right abstraction should stay out of your way. It should not force you to restructure your component tree, bring in heavy dependencies, or fight the framework.

useSyncExternalStore gave us the missing piece. It made external stores a first-class pattern in React without the ceremony of context providers. For modals - where state is inherently global and actions need to be callable from anywhere - that is exactly the right fit.

If you are dealing with the same problem, check out react-modals on GitHub. It is free, it is small, and it might save you from writing the same boilerplate one more time.