React Portals
A portal lets a component render its children into a DOM node that lives outside its parent’s DOM hierarchy, while the component still stays in its normal place in the React component tree. This solves a very specific and very common problem: UI like modals, tooltips, dropdowns, and toast notifications need to visually escape a parent container (to avoid being clipped by overflow: hidden or trapped by a low z-index), but they still need to behave like a normal part of your React app — receiving props, sharing context, and participating in event handling as if they were nested normally.
Overview / How it works
Normally, when a component returns JSX, React inserts the resulting DOM nodes as children of the DOM node produced by that component’s parent. If a Modal component is rendered deep inside a <div class="card"> that has overflow: hidden and position: relative, the modal’s DOM output is physically trapped inside that card — it gets clipped, or its z-index only competes within that card’s stacking context. CSS alone cannot reliably fix this in all cases, because ancestor styles like overflow, transform, or z-index create new stacking/clipping contexts that affect everything nested inside them, no matter how high you set a child’s own z-index.
React Portals solve this at the DOM level rather than the CSS level. createPortal(children, domNode) tells React: “render this JSX, but attach the resulting DOM nodes as children of domNode instead of the parent’s DOM node.” Crucially, this is only about where the DOM nodes are attached. The component itself is unaffected in React’s terms: it still lives at the same place in the component tree, it still receives the same props, it can still read the same React Context from its ancestors, and — this surprises a lot of developers — events dispatched from inside a portal still bubble up through the React component tree, not through the DOM tree. So a click inside a portal-rendered modal will still trigger an onClick handler on a React ancestor of the Modal component, even though in the actual DOM the modal’s markup is sitting somewhere completely different (often as a direct child of <body>).
This matters for the render → reconcile → commit cycle too. During the render phase, React builds the tree of elements as usual, including the portal’s children. During commit, React inserts the DOM produced by the portal’s children into the target node you specified, rather than into the parent’s DOM subtree. On unmount, React removes those DOM nodes from the target, just as it would from a normal parent. State updates inside a portal’s children re-render exactly like any other component — the portal is a rendering destination, not a special component with its own lifecycle.
Syntax
import { createPortal } from "react-dom";
createPortal(children, domNode, key?)
| Part | Meaning |
|---|---|
children |
Any renderable React node (JSX, string, fragment) — what you want rendered. |
domNode |
An existing DOM element (e.g. from document.getElementById("modal-root")) that the children will be attached to. |
key (optional) |
A unique key, useful if you render multiple portals dynamically (e.g. a list of toasts). |
| Return value | A special React node you return from your component’s render output, just like any JSX. |
You typically call createPortal inside a component’s return statement, and that target domNode is usually a static element you add to your HTML shell (like <div id="modal-root"></div> next to <div id="root"></div> in index.html) so it sits outside your main React mount point and outside any clipping ancestors.
Examples
Example 1: A basic modal
import { createPortal } from "react-dom";
function Modal({ children, onClose }) {
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.getElementById("modal-root")
);
}
export default Modal;
Renders: nothing visually changes about the JSX layout, but in the actual DOM, the overlay and content divs are inserted as children of #modal-root (assumed to exist in index.html as a sibling of the app’s root div), not inside whatever component tree rendered <Modal>. This means the modal escapes any parent’s overflow: hidden or low z-index stacking context. Clicking the overlay calls onClose; clicking the content stops that click from bubbling to the overlay’s handler via stopPropagation.
Example 2: Using the modal with state in a parent
import { useState } from "react";
import Modal from "./Modal";
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="app">
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<Modal onClose={() => setIsOpen(false)}>
<h3>Confirm Action</h3>
<p>Are you sure you want to continue?</p>
</Modal>
)}
</div>
);
}
export default App;
Renders: a button labeled “Open Modal”. Clicking it sets isOpen to true, causing Modal to mount and its content to appear (via the portal) as an overlay covering the screen, regardless of where App‘s own DOM sits. Even though the modal’s DOM lives outside App‘s DOM subtree, notice that onClose is a normal prop and setIsOpen is a normal piece of state — portals don’t change how props, state, or context flow.
Example 3: A toast notification list with a dedicated portal root
import { createPortal } from "react-dom";
import { useState, useCallback } from "react";
function ToastContainer({ toasts }) {
const root = document.getElementById("toast-root");
if (!root) return null;
return createPortal(
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className="toast">
{toast.message}
</div>
))}
</div>,
root
);
}
function useToasts() {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((message) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
return { toasts, addToast };
}
export { ToastContainer, useToasts };
Renders: nothing until addToast is called; then a small toast box with the given message appears (via the portal, inside #toast-root), stacking with any other active toasts, and automatically removes itself after 3 seconds. This shows a defensive null check (if (!root) return null) guarding against a missing target node, and a key on each mapped toast, both of which matter in real portal-based UI.
How it works step by step / Under the hood
On mount: React renders the component’s JSX as usual during the render phase, producing a portal element wrapping the children. During the commit phase, instead of inserting the resulting DOM nodes next to the parent’s DOM output, React appends them as children of the target domNode you passed in. The component’s position in the React tree (for context, prop flow, and reconciliation) is untouched — only the DOM insertion point differs.
On update: if the portal’s children re-render (new props, new state), React reconciles them exactly like any other subtree and patches the DOM inside the target node, leaving everything else alone.
On unmount: React removes the DOM nodes it had inserted into the target, the same as it would clean up a normal child. If you created the target domNode dynamically (e.g. via document.createElement) rather than using a static node from index.html, you are responsible for appending it to the document (usually in an effect) and removing it yourself when the component unmounts, or you’ll leak DOM nodes.
Events: because the portal only changes DOM placement, not React-tree placement, synthetic events dispatched inside the portal still propagate through React’s event system along the component tree — meaning a parent component’s onClick can still catch a click that originated inside a portal, even though that click physically happened on a DOM node elsewhere in the page.
Common Mistakes
Mistake 1: Assuming portals break event bubbling
function Parent() {
return (
<div onClick={() => console.log("parent clicked")}>
<Modal>...</Modal>
</div>
);
}
Many developers expect this onClick to never fire when something inside Modal‘s portal is clicked, because the DOM nodes are physically elsewhere. In reality, React bubbles the event through the component tree, so the parent’s handler DOES fire. If you don’t want that, call e.stopPropagation() inside the portal’s click handler, as shown in Example 1.
Mistake 2: Forgetting to guard against a missing target node
function Tooltip({ text }) {
return createPortal(
<div className="tooltip">{text}</div>,
document.getElementById("tooltip-root")
);
}
If #tooltip-root doesn’t exist yet in the DOM (e.g. the HTML shell was edited, or this runs in a test environment without it), createPortal receives null as its second argument and throws. Always ensure the target exists, or check for it and render null as a fallback:
function Tooltip({ text }) {
const root = document.getElementById("tooltip-root");
if (!root) return null;
return createPortal(<div className="tooltip">{text}</div>, root);
}
Mistake 3: Mutating state directly when managing a list rendered through a portal
function addToast(message) {
toasts.push({ id: Date.now(), message }); // mutates state directly
setToasts(toasts);
}
Mutating the array in place and passing the same reference back to setToasts means React may not detect a change and skip re-rendering the portal’s contents. Always create a new array:
function addToast(message) {
setToasts((prev) => [...prev, { id: Date.now(), message }]);
}
Best Practices
- Add a dedicated, static target element (e.g.
<div id="modal-root"></div>) in your HTML shell as a sibling of your app’s root, rather than creating and destroying DOM nodes on every render. - If you must create the target node dynamically, create and append it in an effect on mount, and remove it in the effect’s cleanup function to avoid leaking nodes.
- Always call
stopPropagation()on inner click handlers (like a modal’s content box) if you don’t want an overlay’s outer click handler (used to close the modal) to fire for clicks inside the content. - Use portals for UI that needs to visually escape a clipping or stacking context: modals, dialogs, tooltips, dropdown menus, and toast/notification stacks are the classic use cases.
- Remember that context and props still flow normally into portal content — you don’t need any special provider setup just because you’re using a portal.
- Give every dynamically rendered item inside a portal (like a list of toasts) a stable, unique
key, exactly as you would outside a portal. - Don’t reach for a portal just to change visual stacking order if a simple
z-indexand repositionedoverflowon an ancestor would do — portals add indirection and are best reserved for genuine escape-the-container needs.
Practice Exercises
- Build a
Drawercomponent that slides in from the side of the screen usingcreatePortalinto a#drawer-rootnode, with anisOpenprop and anonClosecallback triggered by clicking outside the drawer. - Extend the toast example from Example 3 so that each toast has a
type("success","error","info") that changes its CSS class, and add a manual close button on each toast in addition to the automatic 3-second timeout. - Create a
Tooltipcomponent that positions itself next to a trigger element using the trigger’s bounding rectangle (via arefandgetBoundingClientRect()), rendering its content through a portal intodocument.bodyso it’s never clipped by any ancestor.
Summary
createPortal(children, domNode)fromreact-domrenders children into a different DOM location while keeping the component in its normal place in the React component tree.- Portals solve clipping and stacking-context problems (from
overflow: hidden, lowz-index,transformancestors) that CSS alone can’t reliably fix — common uses are modals, tooltips, dropdowns, and toasts. - Events dispatched inside a portal still bubble through the React component tree, not the DOM tree — a parent’s event handler can still catch them.
- Props, state, and context all flow into portal content exactly as they would for any other child — only the DOM insertion point changes.
- Always guard against a missing target node, clean up dynamically created target nodes, and keep state updates immutable when managing lists rendered through a portal.
