Concept
The beginner framing: createPortal lets a component render its output somewhere else in the actual page, commonly used for modals, tooltips, and dropdowns that need to visually escape a parent container's overflow: hidden or a limiting z-index stacking context.
The precise mental model: a portal splits a component's two "positions" apart. Where it renders in the real DOM is wherever you point it, any DOM node you pass as the portal's target. Where it lives in the React tree, for props, context, and event handling purposes, stays exactly where it's written in your JSX, completely unaffected by the portal.
function Modal({ children }) {
return createPortal(
<div className="modal-overlay">{children}</div>,
document.getElementById("modal-root") // a DOM node OUTSIDE the app's normal container
);
}
function App() {
return (
<div className="app" onClick={() => console.log("app clicked")}>
<Modal>
<button onClick={() => console.log("button clicked")}>Close</button>
</Modal>
</div>
);
}Even though the rendered <button> physically lives inside #modal-root, completely outside .app's DOM subtree, clicking it still logs both "button clicked" and "app clicked", because Modal (and everything inside it) is still a React-tree child of App, and React's event bubbling follows the React tree.
The synthetic event system: one listener, not thousands
React doesn't attach a native event listener to every single element you give an onClick to. Instead, it attaches a small number of listeners at the root of the app once, and when a real DOM event fires, React figures out which component's handlers should run by walking its own React tree (using the "synthetic event" it constructs to wrap the native one), simulating bubbling through the component structure, not the raw DOM structure.
This is exactly why the portal example above works: the native DOM event fires on the button inside #modal-root, React's root-level listener catches it, and React's own bubbling simulation walks up the React tree (button → Modal → App), not the real DOM's parent chain (button → #modal-root → document.body).
Try It
Predict what happens before checking the solution.
function Tooltip({ children }) {
return createPortal(children, document.body);
}
function Card() {
return (
<div onMouseEnter={() => console.log("Card hover")}>
<Tooltip>
<span onMouseEnter={() => console.log("Tooltip hover")}>Info</span>
</Tooltip>
</div>
);
}Hovering over the <span>, does "Card hover" log, even though <span> is portaled directly into document.body, nowhere near Card's actual DOM subtree?
Solution
Yes, both "Tooltip hover" and "Card hover" log. Tooltip is a React-tree child of Card, exactly as written in JSX, regardless of the fact that createPortal sends its rendered output to document.body in the real DOM. React's synthetic event system bubbles the mouseenter event through the React tree (span → Tooltip → Card), completely independent of where those elements physically sit in the document.
Implement It Yourself
Build a simplified version of React's delegated event system to see exactly how one root listener replaces thousands of individual ones:
const handlerRegistry = new Map(); // maps a React "fiber-like" node to its handlers
function attachRootListener(rootDomNode) {
rootDomNode.addEventListener("click", (nativeEvent) => {
// In real React, this walk uses the fiber tree; here we simulate
// walking up the REACT tree structure (not necessarily the DOM tree).
let reactNode = findReactNodeFor(nativeEvent.target);
while (reactNode) {
const handler = handlerRegistry.get(reactNode)?.onClick;
if (handler) handler(nativeEvent);
reactNode = reactNode.reactParent; // the REACT-TREE parent, which may differ from the DOM parent for a portaled node
}
});
}
The critical detail: reactNode.reactParent is not the same thing as the DOM node's actual parentElement when a portal is involved, it's whatever the component's logical parent is in the JSX tree, which is exactly what makes the portal-and-bubbling behavior from Try It work.
Under the Hood
The synthetic event system's single, delegated root listener is a classic instance of the observer/event-delegation pattern, one central dispatcher instead of many individual subscribers, reducing per-element setup cost regardless of how many elements exist. And the fact that a portal's children stay in the same React-tree position while moving to a different DOM position is a direct consequence of the same distinction from Components: the React tree is a tree of function calls and their logical nesting, while the DOM tree is just wherever those function calls' output happened to be attached, a portal simply detaches the second thing from the first, on purpose.
Common Mistakes
1. Assuming a portal's content inherits CSS from its "logical" JSX ancestors
// .app-theme sets colors via CSS class chain, but the portal's
// content is rendered OUTSIDE .app's actual DOM subtree
<div className="app-theme">
<Modal>...</Modal> {/* renders into document.body, .app-theme's CSS descendant selectors DON'T reach it */}
</div>CSS cascades through the real DOM tree, not the React tree, a portal's content needs its own styling context (its own class names, or CSS custom properties/inherited context values passed explicitly), since CSS selectors relying on DOM ancestry simply won't match content rendered elsewhere in the document.
2. Forgetting the portal's target DOM node needs to actually exist
createPortal(children, document.getElementById("modal-root")); // ❌ null if #modal-root isn't in the HTML yetThe target node must already exist in the DOM before the portal tries to render into it, commonly ensured by including a dedicated container element in the base HTML template, or verifying the ref/node exists before rendering the portal at all.
3. Assuming event delegation means events "skip" intermediate DOM elements
Delegation changes where the listener is physically attached (the root), not the logical bubbling order components observe, from a component author's perspective, onClick still fires in the expected parent-to-child (capture) or child-to-parent (bubble) order along the React tree; delegation is an implementation detail for efficiency, not a change to the event model components see.
Best Practices
- Reach for portals specifically when content needs to visually escape a container's
overflow/z-index/stacking context, modals, tooltips, dropdowns, toasts. - Remember context and event bubbling still follow the React tree for portaled content, a portal doesn't need special handling to keep working with an ancestor's Context Provider; it "just works" the same as any other child.
- Style portaled content independently of its logical JSX ancestors' CSS, since the DOM tree (which CSS follows) is not the same as the React tree anymore.
- Ensure the portal's target DOM node exists before rendering into it, typically via a dedicated element already present in the base HTML.
Performance Tips
- The synthetic event system's single delegated listener scales well, adding thousands of individual elements with their own
onClickdoesn't add thousands of native listeners, keeping memory and setup cost low regardless of tree size. - Portals themselves add no meaningful overhead beyond an ordinary render, they're simply targeting a different DOM container, not doing extra reconciliation work (see Reconciliation).
