What are React Portals and when to use them
Portals render a child into a DOM node outside the parent's hierarchy while preserving React's component tree (state, context, events). Used for modals, tooltips, popovers, toasts — anything that needs to escape `overflow:hidden` or stacking context but stay logically inside the component. Events still bubble through React's virtual tree, not the DOM ancestor.
What they are
import { createPortal } from 'react-dom';
function Modal({ children, open }) {
if (!open) return null;
return createPortal(
<div className="modal">{children}</div>,
document.body,
);
}The JSX renders into document.body, but in the React tree it's still a child of where <Modal> was used. Context, event delegation, and state all behave as if it's nested normally.
Why we need them
CSS stacking context and overflow are scoped:
<div style="overflow: hidden; position: relative; z-index: 0;">
<Modal /> <!-- visually clipped or layered wrong -->
</div>Portals escape: render to body (or a dedicated root container) so z-index and overflow are global.
When to use
- Modals / dialogs.
- Tooltips / popovers.
- Toasts / notifications.
- Dropdown menus that overflow their container.
- Drag overlays.
What still works
- React context (consumer in portal reads parent provider).
- Event bubbling through React's synthetic system (clicks bubble up the virtual tree, not the DOM).
- State and refs.
- Suspense / error boundaries.
What doesn't
- CSS inheritance via the DOM tree (since the actual ancestor is body, not the original parent).
- Native event bubbling in the DOM ancestor.
A11y considerations
- Focus trap inside dialogs (Radix Dialog, react-focus-lock).
aria-hiddenon the rest of the page when modal is open.role="dialog"+aria-modal="true"on the dialog element.- Restore focus to the trigger when closed.
- Escape to close.
Adopt Radix Dialog / Headless UI Dialog — these are non-trivial to do right.
SSR
createPortal only works in the browser (needs document). Guard:
function Modal({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(<div>...</div>, document.body);
}Dedicated portal root
Some apps create a dedicated root:
<div id="app"></div>
<div id="portals"></div>Easier to clean up; predictable z-index stacking.
Anti-patterns
- Portals for non-overlay UI (just use normal positioning).
- DIY focus management instead of a library.
- Multiple stacked modals fighting for focus.
Interview framing
"Portals render a child to a DOM node outside the parent hierarchy while preserving the React tree — context, state, and event bubbling work as if normally nested. Used for overlays: modals, tooltips, popovers, toasts, dropdown menus that overflow. Without them you fight overflow:hidden and z-index stacking. Pair with proper a11y: role="dialog", focus trap, escape to close, restore focus on close. Adopt Radix Dialog or Headless UI for the a11y — rolling your own focus management is a months-long project."
Follow-up questions
- •Why do events still bubble through the React tree?
- •What's a focus trap and why do dialogs need one?
- •When would you NOT use a portal?
Common mistakes
- •Skipping a11y (no focus trap, no aria).
- •Portals for non-overlay UI.
- •SSR breakage from createPortal without guard.
Performance considerations
- •No special cost. React reconciles the portal subtree normally.
Edge cases
- •Portals + Strict Mode double-render.
- •Nested portals.
- •CSS inheritance through portal boundary.
Real-world examples
- •Radix Dialog, Headless UI Dialog, react-toastify, MUI Modal.