React

Portals

14 Questions

React portals let you render a component's output into a different DOM node than its parent, while keeping the component itself inside the React tree for events, context, and lifecycle. This solves a fundamental CSS layout problem: overlays like modals and tooltips need to sit at the top of the DOM to avoid z-index and overflow clipping issues, but their logic belongs in the component that triggers them. Use ReactDOM.createPortal(children, domNode) to redirect rendering to any DOM element — the React tree relationship is preserved, only the DOM output location changes.

import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="modal">{children}</div>,
    document.getElementById('modal-root')
  );
}

Why it matters: Portals let you render UI outside the current DOM parent (like a modal at the body level) while keeping React context and event bubbling working normally.

Real applications: Modals, tooltips, dropdowns that need to escape overflow:hidden or z-index stacking issues in deeply nested containers.

Common mistakes: Forgetting to add the target element (e.g. modal-root) to index.html before calling getElementById — it returns null and portals silently break.

Use portals when a component's visual output needs to escape its container's CSS constraintsoverflow: hidden, z-index stacking contexts, or CSS transform limitations that would clip or misposition its content. Portals add complexity, so only reach for them when regular DOM nesting and CSS cannot solve the problem. Common scenarios include:

  • Modals and dialogs
  • Tooltips and popovers
  • Dropdown menus that need to overflow parents
  • Toast notifications
  • Any UI that should visually break out of its container

Why it matters: CSS overflow, z-index, and positioning constraints from parent elements can break fixed overlays — portals escape those constraints entirely.

Real applications: A modal inside a position: relative container with overflow: hidden would be clipped — a portal renders it at the body level instead.

Common mistakes: Using portals when simple CSS would work — portals add complexity, so only use them when you actually need to escape the DOM hierarchy.

One of the most important portal behaviors is that events bubble through the React component tree, not through the DOM tree where the portal renders. A click inside a modal portal fires onClick on the React ancestor containing <Modal>, even though the portal content is in a completely different DOM node. This is usually the desired behavior for React's synthetic event system. Use e.stopPropagation() inside the portal when you specifically need to prevent this bubbling.

Why it matters: Event bubbling through the React tree means parent components can handle portal events normally — you don't need extra event wiring.

Real applications: A parent component can close a modal portal by placing an onClick handler on the parent — the click event bubbles up through the React tree.

Common mistakes: Expecting DOM-level bubbling — a click inside a portal won't bubble to a DOM ancestor that isn't the React parent, only to React tree parents.

Since portals remain part of the React component tree, they fully inherit all React Context values from their ancestors, regardless of where the portal renders in the actual DOM. A modal portal can read auth context, theme context, or locale context from providers high in the tree without any extra setup or re-providing. This is one of the key advantages of portals over manually rendering React trees into separate DOM nodes, which would require supplying all context again.

Why it matters: Portals don't break context — a modal portal still receives the user's auth context from a provider high in the tree.

Real applications: A portal-based modal can read theme context, auth context, or locale context from the app's providers without any extra setup.

Common mistakes: Wrapping the portal mount point in a separate context provider — unnecessary, since the portal already inherits context from its React parent.

To create a modal with a portal, render the overlay and modal content via createPortal targeting document.body or a dedicated mount point. The onClick={onClose} on the overlay backdrop closes the modal when users click outside the content — the stopPropagation call on the inner modal div is critical to prevent clicks on the content from bubbling to the backdrop and closing the modal unintentionally.

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  return createPortal(
    <div className="overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

Why it matters: Modals rendered via portals at document.body always appear above everything else — no z-index battles with parent containers.

Real applications: Confirmation dialogs, image lightboxes, cookie consent banners — any overlay that must sit on top of the entire page.

Common mistakes: Forgetting stopPropagation on the inner modal div — without it clicking the content closes the modal because the click bubbles to the overlay's onClick.

Keyboard accessibility is a WCAG requirement for modal dialogs — users must be able to dismiss modals with the Escape key without a mouse. The safest approach adds a keydown listener on document inside a useEffect. The cleanup function is critical: without it, the listener persists after the modal closes and every Escape press fires onClose indefinitely. Always pair keyboard handling with focus management for full accessibility compliance.

useEffect(() => {
  const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
  document.addEventListener('keydown', handleEsc);
  return () => document.removeEventListener('keydown', handleEsc);
}, [onClose]);

Why it matters: Keyboard-accessible modals are a WCAG requirement — users must be able to dismiss a modal with the Escape key without reaching for a mouse.

Real applications: Any modal dialog — press Escape to dismiss is universal UX that users expect and assistive technologies depend on.

Common mistakes: Forgetting the cleanup function in useEffect — the event listener persists after the modal closes, calling onClose on every Escape press forever.

The portal's target DOM node determines its visual stacking order and whether it inherits problematic CSS from the app container. document.body is simplest but can inherit global body styles. A dedicated <div id="portal-root"> as a sibling of the app root in index.html is the cleanest approach — it sits completely outside the app's CSS cascade. Avoid rendering portals inside the app root div as app-level CSS will leak into overlay styles.

Why it matters: Choosing the right mount point prevents z-index conflicts, layout jank, and unwanted style inheritance from the app root.

Real applications: Add <div id="portal-root"></div> to index.html as a sibling of the app root — modals mount there cleanly outside all app styles.

Common mistakes: Mounting portals inside the app's root div — they inherit app-level CSS that can conflict with the overlay styles.

Nested portals work correctly — each portal independently renders at its target DOM node, and the React component hierarchy is fully respected for events and context. A tooltip portal inside a modal portal both render at the top of the DOM, but React still knows their component relationship. The key behavior to remember: events bubble through React tree position, not through DOM tree position, so nested portals behave predictably for event handling.

Why it matters: Nested portals work correctly for things like a tooltip inside a modal — both render outside their DOM parents but still behave as React children.

Real applications: A confirmation dialog portal opened from inside a main modal portal — both render at document body level but maintain correct React event and context hierarchy.

Common mistakes: Expecting DOM-ordered event bubbling in nested portals — events bubble by React tree position, not by DOM nesting order.

Portals introduce accessibility challenges because they disrupt the natural DOM flow browsers use for tab order and screen reader navigation. For modal portals, you must: set role="dialog" and aria-modal="true" so screen readers recognize the modal, implement a focus trap to prevent Tab from reaching background content, and return focus to the trigger element when the modal closes. Skipping any of these leaves the modal inaccessible to keyboard and screen reader users.

Why it matters: A modal without focus trapping lets keyboard users tab behind it — screen reader users may not even know the modal is open.

Real applications: Use role="dialog", aria-modal="true", trap focus inside the modal on open, and return focus to the trigger element on close.

Common mistakes: Only styling the visual overlay without handling focus — visually it looks like a modal, but keyboard and screen reader users can still reach content behind it.

Since portals require a real browser DOM node, they cannot render during server-side rendering — there is no document in Node.js. Calling document.getElementById during SSR throws a reference error. The solution is a mounted state flag: initialize to false, set to true inside useEffect, and only render the portal when mounted — ensuring it only activates after client-side hydration is complete.

Why it matters: SSR environments have no document object — calling document.getElementById during server render throws an error.

Real applications: In Next.js, wrap portal rendering in a useEffect with a mounted state flag so portals only render after hydration.

Common mistakes: Calling document.getElementById directly during render in an SSR app — guard it with a mounted flag initialized in useEffect.

A usePortal hook encapsulates the lifecycle of a dynamically created portal mount point — it creates and appends the target DOM node when the component mounts and removes it on unmount to prevent DOM leaks. This avoids needing a pre-existing portal-root div in index.html. The cleanup in useEffect is required — forgetting it leaves orphaned DOM nodes accumulating in the document body each time the component mounts and unmounts.

function usePortal(id = 'portal-root') {
  const [el] = useState(() => document.createElement('div'));

  useEffect(() => {
    const target = document.getElementById(id) || document.body;
    target.appendChild(el);
    return () => target.removeChild(el);
  }, [el, id]);

  return el;
}

function Tooltip({ children }) {
  const target = usePortal();
  return createPortal(children, target);
}

The hook encapsulates portal lifecycle management so individual components don't need to manually create and clean up DOM nodes.

Why it matters: A reusable hook means you write the create/append/remove DOM logic once and every component gets portals for free.

Real applications: Any component that needs to render content outside its DOM parent — import the hook, use the returned element as the portal target.

Common mistakes: Forgetting the cleanup in useEffect — the hook must remove the dynamically created DOM node when the component unmounts to avoid DOM leaks.

This often surprises developers: portal events bubble through the React component hierarchy, not through the DOM hierarchy where portal content renders. A click inside a portal fires onClick handlers on every React ancestor of the portal component — not ancestors in the DOM. This maintains React's consistent event model across portals, but requires careful use of e.stopPropagation() when you need to prevent the bubble from reaching React parent handlers.

function Parent() {
  return (
    <div onClick={() => console.log('Parent clicked!')}>
      <Modal>
        {/* clicking here also triggers Parent's onClick */}
        <button>Portal Button</button>
      </Modal>
    </div>
  );
}
// Fix: use e.stopPropagation() inside the portal if needed

This is usually the desired behavior — portals stay part of the component hierarchy for state and events. Use stopPropagation when you need to prevent the bubble.

Why it matters: Knowing events bubble through the React tree (not the DOM) prevents confusion when a parent handler fires unexpectedly from portal clicks.

Real applications: A parent with an onClick that resets state will fire when the user clicks inside the portal — use stopPropagation in the portal to prevent this.

Common mistakes: Assuming portal events only bubble within the portal's DOM subtree — they actually bubble all the way up the React component tree.

A portal-based toast system uses createPortal to render all notifications at document.body, ensuring they always appear above every other element regardless of z-index stacking. The Context + portal combination is key: Context provides an addToast function to any component, while the portal renders the notification container outside the app div. This pattern decouples notification triggering from rendering — any component in the tree can show a toast without managing DOM positioning.

const ToastContext = createContext(null);

function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);
  const add = (msg) => setToasts(t => [...t, { id: Date.now(), msg }]);
  const remove = (id) => setToasts(t => t.filter(x => x.id !== id));

  return (
    <ToastContext.Provider value={add}>
      {children}
      {createPortal(
        <div className="toast-container">
          {toasts.map(t => (
            <div key={t.id} className="toast" onClick={() => remove(t.id)}>
              {t.msg}
            </div>
          ))}
        </div>,
        document.body
      )}
    </ToastContext.Provider>
  );
}

const useToast = () => useContext(ToastContext);

Any component calls const toast = useToast(); toast('Saved!') to show a notification without worrying about z-index or positioning.

Why it matters: A centralized toast system means any component in the tree can show notifications without managing DOM positioning or z-index itself.

Real applications: After a form save, an API call success, or an error — any component can trigger a toast with one line of code.

Common mistakes: Rendering toasts inside individual components — they get clipped by parent CSS and their position is unpredictable; always render them at a top-level portal.

A focus trap is an accessibility requirement for modal dialogs — it constrains Tab and Shift+Tab so keyboard users cannot navigate to background content while the modal is open. Without it, users can Tab past the Close button and interact with hidden page content behind the overlay. The focus-trap-react library handles all edge cases for you. Equally critical: when the modal closes, focus must return to the trigger element (the Open button) so keyboard users don't lose their place in the page.

// Using focus-trap-react library
import FocusTrap from 'focus-trap-react';

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  return createPortal(
    <FocusTrap>
      <div role="dialog" aria-modal="true">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>,
    document.body
  );
}

Focus trapping is essential for accessibility (WCAG 2.1). Without it, keyboard users can accidentally interact with content behind an open modal.

Why it matters: Without focus trapping, pressing Tab in a modal eventually moves focus out of it — keyboard users interact with hidden background content.

Real applications: Use focus-trap-react or Radix UI's built-in focus management for dialogs, drawers, and any full-screen overlays.

Common mistakes: Forgetting to return focus to the trigger element when the modal closes — users lose their place in the page after dismissing the modal.