codejump

AcademyReact

Portals and Rendering Outside the Tree

How createPortal splits DOM position from React-tree position, why modals need it, and where events actually bubble.

3 min readUpdated

3 exercises

Waiting at the end of this page.

Two trees, not one

Most of the time the DOM tree and the React tree are the same shape: a component renders an element, that element is a child of whatever DOM node its parent rendered into. createPortal is the one API that breaks that assumption on purpose.

JSX
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(children, document.getElementById('modal-root'));
}

children renders into #modal-root — wherever that node sits in the actual DOM, likely a direct child of <body>. But in the React tree, children is still exactly where <Modal> was written: nested under whatever parent rendered it, subject to the same context providers, the same error boundaries, the same reconciliation as if no portal existed at all. The DOM position moved. The component's place in the tree React reasons about did not.

Why this exists

CSS gives you z-index and position: fixed, and most of the time that is enough. It stops being enough the moment a modal is rendered from deep inside a component that a parent has styled with overflow: hidden or its own z-index stacking context — a card, a table cell, a sidebar panel. A stacking context traps everything inside it, z-index and all; no amount of tweaking the modal's own CSS gets it to paint above content outside that ancestor.

A portal sidesteps the problem entirely by never putting the modal's DOM node inside the trapping ancestor in the first place. #modal-root sits next to #app-root, outside every component's stacking context by construction. This is the standard fix for modals, tooltips, dropdowns and toasts — anything that needs to visually escape wherever it happens to be mounted.

Events still bubble through React, not the DOM

This is the part that reads as a bug the first time you see it and is actually the entire point. A click inside a portaled modal still reaches an onClick handler on a React ancestor of <Modal> — even though, in the real DOM, that modal's markup is not nested inside that ancestor at all:

JSX
function App() {
  return (
    // This handler fires for clicks inside the portaled modal too.
    <div onClick={() => console.log('caught at App')}>
      <Modal>
        <button onClick={() => console.log('button clicked')}>Close</button>
      </Modal>
    </div>
  );
}

Clicking the button logs both lines, in that order. React's synthetic event system dispatches along the component tree it maintains, independent of where the underlying DOM nodes physically live. A form-level onSubmit still fires for a portaled input; a dropdown's "click outside to close" listener attached on a React ancestor still works normally — no special-casing required. The one place this bites: native, non-React listeners attached directly to a DOM node (addEventListener on some ancestor) never see events from a portaled subtree, because those really do follow the DOM tree.

A minimal modal

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

stopPropagation on the dialog stops the overlay's onClick from firing for clicks inside the dialog — ordinary React event handling, exactly as it would work with no portal involved. The portal changed where the markup paints. It changed nothing about how the component behaves.

Now practice it

3 exercises

Reading this page is the cheap half. These are the exercises that make you use it.