Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 16 min read

React modals: what's open is a value, not a boolean

This is the product list of a store's admin panel, with a button to delete each product, written the way component library docs and almost every tutorial teach it: // src/modules/product/components/ProductList/Product

React modals: what's open is a value, not a boolean

This is the product list of a store's admin panel, with a button to delete each product, written the way component library docs and almost every tutorial teach it:

// src/modules/product/components/ProductList/ProductList.tsx
export default function ProductList() {
  const { products, refetch } = useProducts();
  const [isDeleteOpen, setIsDeleteOpen] = useState(false);
  const [selected, setSelected] = useState<Product | null>(null);

  const handleDelete = (product: Product) => {
    setSelected(product);
    setIsDeleteOpen(true);
  };

  return (
    <>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name}
            <button onClick={() => handleDelete(product)}>Delete</button>
          </li>
        ))}
      </ul>

      <DeleteProductDialog
        open={isDeleteOpen}
        product={selected}
        onOpenChange={setIsDeleteOpen}
        onDeleted={refetch}
      />
    </>
  );
}

The component is correct: it does what it promises, it reads in one pass, and a screen with a single confirmation modal can live like this for years without a single incident. What's worth measuring isn't its behavior, but where the answer to one specific question lives: which modal is open right now?.

Five points, and they're worth sorting. The boolean lives in whoever owns the button, so the modal's visibility is an implementation detail of the list. The modal's data is a second piece of state, selected, updated on another line, and its type allows it to be null while the modal is open. The modal is mounted next to the button, inside the list, even though it isn't part of it. The refetch and the close travel through props from whoever opens to whoever confirms. Those four are local nuisances: annoying, but reversible with an afternoon's refactor.

The fifth is of a different kind: no place in the app knows which modal is open. Every component with a button keeps its own piece of the answer, and the screen can only show one thing at a time.

None of this has an observable cost while there's one screen, one or two modals, and nothing that opens them from outside that screen. It becomes measurable with the third modal, the second place that opens the same one, or the first modal that opens another.

Where this is going

The destination, raw and not justified yet. Opening a modal is putting a value in the only slot there is:

// src/modules/product/components/ProductList/ProductList.tsx
<button
  onClick={() => handleOpenModal(new DeleteProductModalProps(product, refetch))}
>
  Delete
</button>

A single component, mounted once in the app, decides which modal to render based on the value's class:

// src/app/Modals.tsx
export default function Modals() {
  const { open } = useModal();

  if (open instanceof DeleteProductModalProps) {
    return <DeleteProduct product={open.product} refetch={open.refetch} />;
  }

  if (open instanceof EditProductModalProps) {
    return (
      <EditProduct
        product={open.product}
        refetch={open.refetch}
        draft={open.draft}
      />
    );
  }

  if (open instanceof CreateCategoryModalProps) {
    return <CreateCategory prev={open.prev} onCreate={open.onCreate} />;
  }

  return null;
}

And the state of every modal in the app is this:

// src/modules/modal/context/ModalProvider.tsx
const [open, setOpen] = useState<ModalProps | null>(null);

The parts worth looking at are the draft of Edit product and the prev of New category: they let a modal open another one and come back without losing what was typed, which is the hard part.

Two costs

1. Impossible states are representable

With a delete modal and an edit modal, the list has two booleans and two selected. Two booleans are four combinations, and the screen can only show three: none, the delete one, or the edit one. With N modals there are 2^N combinations for N+1 possible screens, plus every one in which the modal is open and its selected is null.

None of that fails today, because each handle* sets both variables in order. What fails is the code written to defend against it: every new modal brings its own if (!product) return null, a guard against a state the type should never have allowed. The React docs name it as a principle, "Avoid contradictions in state", and solve it with a form example that replaces isSending and isSent with a single status.

2. The modal lives where the button is

The day Edit product also has to open from the "Product created" toast, the state moves up to the common ancestor of the list and the toast. With a third place (a keyboard shortcut, the search bar) it moves up again. The second-order effect is that every modal opened from several places ends up with its own context or store, and an app with fifteen modals has fifteen implementations of the same thing: a slot that holds what's open and with what data.

a component tree. At the top, App; below it, Layout with three branches: ProductList, Toast and Search. ProductList holds two small boxes,

Three components keep their piece of the answer, and two of them mount the same modal.

What's already solved, and what to rule out

Controlled primitives (Radix, Headless UI, the browser's <dialog>) solve the hard DOM part: focus, inert background, Escape, screen readers. Where their open lives is left to whoever uses them, and Headless UI says it plainly: "Dialogs are controlled components, meaning that you have to provide and manage the open state yourself".

The explicit case for keeping it local has an author. Kent C. Dodds classifies "modal isOpen state" as UI state and, about moving it into a global store, writes: "this is fine for state that is truly global, but for simple state (like whether a modal is open or form input value state) this is a big problem." He deserves the point in full for a modal that only one button opens: lifting it is paying for indirection and getting nothing back. The argument stops holding with the second place that opens the same modal, which is when what's open stops being a component's state and becomes the screen's state.

modalType + modalProps in a store has been the canonical answer since 2016, when Dan Abramov posted it on Stack Overflow: an action describes which modal you want to see, the reducer stores { modalType, modalProps }, and a <ModalRoot> looks the component up in a map. It's a single slot and it solves both costs. What it brings is a string key and an untyped modalProps: the compiler doesn't connect 'DELETE_POST' with what that modal needs.

Per-component global managers, like @ebay/nice-modal-react, add a promise API (await NiceModal.show(...)). Their store is an object keyed by id with several modals visible at once, prop typing is partial, and it hasn't published a release since October 2023.

The modal in the URL (Next.js intercepting routes, React Router's backgroundLocation) makes what's open shareable and resilient to refresh and the back button, at the cost of one route per modal and of everything it needs fitting in the URL.

Family Cost 1: impossible states Cost 2: the modal lives where the button is A modal that opens another
Controlled primitives not addressed not addressed the parent stays mounted
modalType + modalProps solved solved replaces the parent: lost
nice-modal-react partial: several visible at once solved the parent stays mounted
Modal in the URL solved solved one nested route per level

Note: the table measures these two costs and nesting, not accessibility, where the primitives win by a wide margin. Verified in October 2026 against React 19.3.0, @ebay/nice-modal-react 1.2.13, Headless UI 2.2.10, Next.js 16.4.0 and React Router 8.4.0.

Under Abramov's answer is the conversation this article continues. A comment asks to turn the state into a list so you can "open a modal from a modal" and go back. Abramov replies that he has worked with designers who wanted exclusive modals, "so the approach I wrote up solves accidental nesting". And a year later another reader brings up the case neither position solves: a user who has edited some fields, needs a second modal and "will loose the changes he maid" [sic]. Nobody answers.

The thesis

The React docs have an example that makes this argument without naming modals. An accordion with two panels replaces two isActive booleans with an index in the parent, and justifies it like this: "Using the active index instead of two β€œis shown” flags ensured that only one panel is active at a given time."

An open modal is that index at app scale:

Which modal is open is a single piece of data. It's modeled as one slot holding a value (an instance that describes the modal and carries everything it needs to render), not as a boolean per component nor as a stack.

That decides four things when writing code: a class per modal, a single useState in a provider, a single component that mounts the modals, and a shell that doesn't know whether it's open because it only exists while it is.

the same component tree. App now holds a single box,

The three places write to the same slot, and only one component mounts modals.

The implementation, piece by piece

The base class

The whole file:

// src/modules/modal/domain/modal-props.ts
/**
 * Every modal of the app is a subclass. A class and not an interface on purpose:
 * `Modals` narrows with `instanceof`, and interfaces are erased at compile time.
 */
export abstract class ModalProps {}

It's empty, and it's the piece that makes the rest work. It gives the slot a common type and, being a class, it exists at runtime: open instanceof EditProductModalProps narrows the type of open with no switch, no as, and no separate discriminated union with a type: string field that has to be kept in sync with the components.

A class per modal

Everything the modal needs comes in through the constructor, and whoever opens it passes its own refetch:

// src/modules/product/domain/modal/product-modal.ts
export class DeleteProductModalProps extends ModalProps {
  constructor(
    readonly product: Product,
    readonly refetch: () => void,
  ) {
    super();
  }
}

With this, the impossible state from the opening disappears by construction: there's no way to open the delete modal without a product, because there's no way to build a DeleteProductModalProps without one.

The provider and the hook

The provider is a useState:

// src/modules/modal/context/ModalProvider.tsx
export default function ModalProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [open, setOpen] = useState<ModalProps | null>(null);

  // the slot holds class instances, never functions: `setOpen` would run a function as an updater
  const value = useMemo<ModalContextValue>(
    () => ({ open, handleOpenModal: setOpen }),
    [open],
  );

  return (
    <ModalContext.Provider value={value}>{children}</ModalContext.Provider>
  );
}

The comment isn't decoration. useState stores an instance as is, but if it receives a function it runs it as an updater and stores whatever it returns. That's why what travels in the slot are objects, and the functions a modal needs (the refetch) go inside them.

The hook adds closing, which is setting null:

// src/modules/modal/hooks/useModal.ts
export default function useModal() {
  const { open, handleOpenModal } = useContext(ModalContext);

  const handleCloseModal = useCallback(
    () => handleOpenModal(null),
    [handleOpenModal],
  );

  return { open, handleOpenModal, handleCloseModal };
}

Opening a modal while another is open replaces it. There's no need to decide what to do with the previous one, because there's no room for it.

Modals, mounted once

Modals, the one from Where this is going, is mounted only once, inside the provider and outside any screen; never next to a button or inside a row:

// src/app/App.tsx
export default function App() {
  return (
    <ModalProvider>
      <main>
        <h1>Products</h1>
        <ProductList />
      </main>

      <Modals />
    </ModalProvider>
  );
}

This is where the cost is. Each modal is a class and an instanceof branch written by hand, and the compiler doesn't warn you if a class has no branch: the modal simply doesn't render. In exchange, Modals is the only place that knows the modal components, and whoever opens one only knows the class. When the branches grow, they can be split into one component per feature that Modals mounts one after another; it's optional and changes nothing else. In the project this pattern comes from, there are 21 classes and 21 branches split that way across three components.

The shell: its lifecycle is the opening

The shell never receives an open. Modals mounts it when the slot holds its class and unmounts it when it no longer does, so the form's state is born on open and thrown away on close without a single cleanup useEffect. It's built on <dialog> and showModal(), which provide the top layer, the rest of the page made inert, initial focus and implicit aria-modal. Reduced version, without styles or closing on a backdrop click:

// src/modules/modal/components/Modal/Modal.tsx
export default function Modal({
  labelledBy,
  children,
  onClose,
  loading = false,
}: Props) {
  const ref = useRef<HTMLDialogElement>(null);
  const { handleCloseModal: handleCloseSlot } = useModal();

  const handleClose = () => {
    if (loading) return;

    if (onClose) onClose();
    else handleCloseSlot();
  };

  useEffect(() => {
    // the browser doesn't give the focus back when React removes the dialog: keep the opener
    const opener = document.activeElement as HTMLElement | null;

    ref.current?.showModal();
    document.body.style.overflow = "hidden";

    return () => {
      document.body.style.overflow = "";
      if (opener?.isConnected) opener.focus();
    };
  }, []);

  // Escape fires `cancel` and the browser would close the <dialog> on its own, leaving the
  // slot full: the close goes through the slot instead
  const handleCancel = (e: React.SyntheticEvent<HTMLDialogElement>) => {
    e.preventDefault();
    handleClose();
  };

  return (
    <dialog ref={ref} aria-labelledby={labelledBy} onCancel={handleCancel}>
      {children}
    </dialog>
  );
}

Accessibility costs two pieces the browser doesn't provide, and both were measured. The first: if cancel isn't intercepted, Escape closes the native <dialog> while the slot is still full; the component stays mounted, invisible, with React's state saying it's open. The second: when the dialog is unmounted, focus falls to <body>, not to the button that opened it, which is what the WAI-ARIA modal dialog pattern asks for. Hence the opener saved on mount. With a <div> as the container, which is the usual choice, those two come on top of the focus trap, the inert background and the ARIA attributes, all written by hand.

The modal and its hook

The component picks the shell, sets the texts and delegates to its hook, which holds the state and the submit:

// src/modules/product/components/DeleteProduct/DeleteProduct.tsx
export default function DeleteProduct({ product, refetch }: Props) {
  const { handleCloseModal } = useModal();
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    setLoading(true);
    await deleteProduct(product.id);
    setLoading(false);

    refetch();
    handleCloseModal();
  };

  return (
    <FormModal
      title="Delete product"
      nextText="Delete"
      onSubmit={handleSubmit}
      loading={loading}
    >
      <p>β€œ{product.name}” will be deleted. This can't be undone.</p>
    </FormModal>
  );
}

First the list that opened the modal is refreshed, then it closes. While loading is true, the shell ignores Escape and the backdrop: an operation halfway through isn't cancelled by an accidental gesture.

Back to the inventory

The boolean that lived in whoever had the button doesn't exist: opening is building a value. Neither does the second piece of state with the data: the data is inside the value and can't be missing. The modal is no longer mounted next to the button but once, in Modals. The refetch still travels, but inside the instance, not through props from component to component. And the question of which modal is open now has an answer in a single place, with a type that doesn't allow two.

The acid test: the modal that opens another modal

Edit product has its name changed and unsaved, and the category it needs doesn't exist yet. The user clicks New category, which is a modal with its own form. When it's created, the user has to get back to Edit product with the name exactly as they left it and the new category already selected; on cancel, with the name and nothing else. It's the case from the unanswered comment under Abramov's answer, and it's the one that breaks the single slot as it stands so far: opening the child replaces the parent, the parent unmounts and its useState is gone.

The obvious way out is a stack: a ModalProps[] that renders all its entries, with the parent mounted under the child and its draft intact. With <dialog> it works, because the one below becomes inert. But it leaves live state in an array: everything underneath stays mounted, with its effects and its requests, and the implementation has to decide which one is on top, which one gets Escape and what shows up on close. The single slot keeps one live modal and one rule.

The single-slot solution is for the child to receive where to go back to. The parent, before it unmounts, puts what has to survive into a new instance of itself:

// src/modules/product/components/EditProduct/hooks/useEditProduct.ts
// the parent is about to be unmounted: whatever must survive goes into a new instance of itself
const handleNewCategory = () => {
  const current: ProductDraft = { name, price, categoryId };

  handleOpenModal(
    new CreateCategoryModalProps(
      new EditProductModalProps(product, refetch, current),
      (category: Category) =>
        new EditProductModalProps(product, refetch, {
          ...current,
          categoryId: category.id,
        }),
    ),
  );
};

And the parent, when it mounts, starts from that draft if it brings one:

// src/modules/product/components/EditProduct/hooks/useEditProduct.ts
const [name, setName] = useState(draft?.name ?? product.name);
const [price, setPrice] = useState(draft?.price ?? product.price);
const [categoryId, setCategoryId] = useState(
  draft?.categoryId ?? product.categoryId,
);

The child doesn't know who opened it. It receives prev, where Cancel goes back to, and onCreate, which builds where Create goes back to, with the new category already selected. Without the imports:

// src/modules/product/components/CreateCategory/CreateCategory.tsx
export default function CreateCategory({ prev, onCreate }: Props) {
  const { handleOpenModal } = useModal();
  const [name, setName] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    setLoading(true);
    const category = await createCategory(name);
    setLoading(false);

    handleOpenModal(onCreate(category));
  };

  return (
    <FormModal
      title="New category"
      nextText="Create"
      onSubmit={handleSubmit}
      onClose={() => handleOpenModal(prev)}
      loading={loading}
    >
      <label>
        Category name
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>
    </FormModal>
  );
}

prev is a memento: a snapshot of the parent taken right before letting it go. Going back is putting that snapshot in the slot. That's why the same CreateCategory works for a future Create product without touching a line: whoever opens it decides where it goes back to.

The cost is stated right here: only what's put into the instance comes back. Focus inside the parent, its scroll position, an uncontrolled input or an in-flight request don't travel in prev, and the parent comes back without them. In practice it forces the state that matters to be controlled and to fit in an object, which is a healthy constraint for a form and bad news for an editor with an undo history.

two columns with the same three steps: open Edit product, open New category, go back. On the left, the stack: in step two there are two stacked boxes and the draft

In the stack the draft stays alive underneath; with the slot it travels inside the value that says where to go back to.

What didn't change to solve nesting: the provider, the hook, the shell and Modals. None of them knows a modal can open another, because for them opening and going back are the same operation with a different value.

Caveats, and who this is for

There's no exit animation. The shell unmounts in the same render in which the slot becomes null, so nothing is left on screen to animate. Libraries that have one keep the last modal precisely for that reason; the @mantine/modals source explains it in a comment: "Keeping the last one is necessary for providing a clean exit transition." With the single slot you have to do the same on purpose: delay the null until the transition ends.

A modal only exists while it's open, and so does its state. That's what makes it unnecessary to clean anything up on close, and it's also what prevents remembering a draft between openings: if Edit product must recover what was typed after being closed by mistake, that draft has to live outside the modal.

Who it's not for, first: a screen with a single confirmation modal that only one button opens. There, this is a class, a provider and a Modals component to replace a useState, and the right answer is Kent C. Dodds's: the local open of the primitive or of the <dialog>. The balance flips with two or more of these conditions: three or more modals, the same modal opened from more than one place, or modals that open modals.

The rule of thumb fits in one line: if two modals can't be seen at the same time, don't give them two states.

The code

The runnable project is at hgomezrobaina/react-modal-slot-example: the complete product panel, with the pieces from this article and the shell on top of <dialog>, in React 19 and Vite. It starts with npm install && npm run dev.

What's worth looking at isn't that it starts, but its suite: ten tests that run in jsdom in about a second, no browser (npm test). That's where what this article claims is checked: that opening a modal while another is open replaces it and never leaves two dialogs, that Escape closes through the slot and not just the native <dialog>, that focus goes back to the button that opened the modal, that the parent unmounts while the child is open, and that New category returns to Edit product with the unsaved name and the new category already selected, or with just the name if cancelled. What jsdom doesn't implement (the top layer and the inert background) was checked separately in Chromium.

Let's talk

Questions, feedback or a different take? I would love to read you. You can find me here:

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.