10LOC
Reactintermediate

Portals for rendering a modal outside the DOM hierarchy

Published February 16, 2027

import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";

export const Modal = ({ children, onClose }: { children: ReactNode; onClose: () => void }) => {
  const [container] = useState(() => document.createElement("div"));

  useEffect(() => {
    document.body.appendChild(container);
    return () => {
      document.body.removeChild(container);
    };
  }, [container]);

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>{children}</div>
    </div>,
    container
  );
};

What

createPortal renders a subtree into a DOM node other than the component's normal parent — commonly document.body — while keeping it in the same place in React's component tree for context, event bubbling, and state.

Why it matters

A modal nested deep inside a component tree inherits every ancestor's overflow: hidden, transform, or z-index stacking context, any of which can silently clip or mis-position it — CSS problems that have nothing to do with the modal itself. Rendering it into a portal attached to document.body sidesteps all of that: the modal's DOM position is independent of where <Modal> appears in JSX, while a click inside it still bubbles up through the React tree (so an onClick on a wrapping <form> still fires), because portals only change DOM placement, not the React tree.

How it works

  • container is a dedicated <div> created once per Modal instance via a lazy useState initializer, rather than a shared node — each open modal gets its own isolated element.
  • The effect appends container to document.body on mount and removes it on unmount, so nothing lingers in the DOM after the modal closes.
  • createPortal(children, container) is what actually redirects rendering: React commits children into container, wherever that node lives in the document, instead of into Modal's own DOM parent.
  • onClick on the inner .modal-content calls stopPropagation so clicking inside the modal doesn't bubble to the overlay's onClick={onClose} and close it immediately.

Gotchas

  • Portals don't escape React context or event bubbling — only DOM placement. Don't reach for one just to "skip" a context provider; it won't.
  • Creating the container in useState (not a plain variable or useRef) is deliberate: it guarantees the same node survives across re-renders without being recreated, while still being created exactly once.
  • Rendering into document.body directly assumes it exists — guard this (or skip the portal) when server-rendering, since there's no DOM at all during that pass.

Related

  • <dialog> — the native HTML element covers a lot of the same ground (top-layer rendering, focus trapping, Esc to close) for a plain modal without any portal code at all.
  • Focus trapping and the inert attribute — a portal solves positioning; accessible modals still need to manage focus and background interaction separately.