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
containeris a dedicated<div>created once perModalinstance via a lazyuseStateinitializer, rather than a shared node — each open modal gets its own isolated element.- The effect appends
containertodocument.bodyon 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 commitschildrenintocontainer, wherever that node lives in the document, instead of intoModal's own DOM parent.onClickon the inner.modal-contentcallsstopPropagationso clicking inside the modal doesn't bubble to the overlay'sonClick={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 oruseRef) is deliberate: it guarantees the same node survives across re-renders without being recreated, while still being created exactly once. - Rendering into
document.bodydirectly 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,Escto close) for a plain modal without any portal code at all.- Focus trapping and the
inertattribute — a portal solves positioning; accessible modals still need to manage focus and background interaction separately.