Difficulty: Intermediate
What are React Portals? When and why would you use them?
React Portals are one of those features that you might not use every day, but when you need them, nothing else will do. They solve a very specific and common problem in web development, and understanding them well shows that you grasp both React's component model and how it interacts with the browser's DOM.
Normally, when a React component renders, its output is inserted into the DOM as a child of the nearest parent DOM node. Your component tree maps directly to the DOM tree. Portals break this rule -- they let you render a component's output into a completely different DOM node, anywhere in the document, while keeping the component in its original position in the React tree.
Why would you want this? The answer is CSS. Specifically, CSS properties that constrain their children's rendering. The most common culprits are overflow: hidden (clips content that extends beyond the element's bounds), z-index stacking contexts (creates layering contexts that limit how children can overlap other elements), and transform/filter/perspective (which create new stacking contexts as a side effect). When your modal, tooltip, or dropdown is nested inside a container with overflow: hidden, it gets clipped. When it is inside a complex z-index hierarchy, it might appear behind other elements regardless of its own z-index.
Portals solve this by rendering the element outside of these constraining containers -- typically directly on document.body or a dedicated portal container. The modal renders on top of everything because it is not constrained by any parent's CSS properties.
You create a portal with createPortal(children, domNode) from react-dom. The first argument is what to render (your modal, tooltip, etc.), and the second is the DOM node to render into. In your index.html, you typically add a second root element: <div id="portal-root"></div> next to your main <div id="root"></div>.
Here is the really interesting part that interviewers love to test: even though the portal's DOM node lives outside the React root, the portal still behaves as a normal child in the React component tree. This has two important implications.
First, events bubble through the React tree, not the DOM tree. If you click a button inside a portal, the click event bubbles up through the React component hierarchy to the portal's parent, grandparent, and so on -- even though in the DOM, the button is nowhere near those elements. This is because React implements its own event system (Synthetic Events) on top of the DOM's event system, and it follows the React tree structure.
Second, React Context still works. A portal child can consume context from providers that are ancestors in the React tree, even though the portal is in a different part of the DOM tree. Your modal can access the theme context, the auth context, or any other provider that wraps it in the React component hierarchy.
The most common use cases for portals are modals and dialogs (need to escape overflow and z-index constraints), tooltips and popovers (need to position freely without clipping), dropdown menus (especially in scrollable containers), toast notifications (need to be on top of everything), and full-screen overlays.
Accessibility is a critical consideration with portals. When you render a modal via a portal, you need to manage focus correctly: move focus into the modal when it opens, trap focus inside the modal (Tab should cycle through modal elements, not escape to the page behind), handle Escape key to close the modal, and restore focus to the trigger element when the modal closes. You also need proper ARIA attributes: role='dialog', aria-modal='true', and aria-labelledby pointing to the modal's title.
In practice, most teams use a component library that handles portal creation and accessibility automatically -- Radix UI's Dialog, Headless UI's Dialog, or similar. But understanding how portals work under the hood is important for interviews and for debugging.
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content"
onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose} aria-label="Close">
X
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.getElementById('portal-root') // renders outside #root
);
}
// Usage: component tree is preserved
function UserSettings() {
const [showModal, setShowModal] = useState(false);
return (
<div className="settings" style={{ overflow: 'hidden' }}>
<h1>Settings</h1>
<button onClick={() => setShowModal(true)}>Delete Account</button>
{/* Renders in #portal-root, NOT inside .settings */}
{/* So overflow: hidden doesn't clip the modal */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Confirm Delete"
>
<p>Are you sure you want to delete your account?</p>
<button onClick={() => setShowModal(false)}>Cancel</button>
</Modal>
</div>
);
}
// In index.html:
// <div id="root"></div>
// <div id="portal-root"></div>
The modal renders into #portal-root (outside the main #root), bypassing CSS constraints like overflow: hidden. But React events and context still work as if it were inside UserSettings.
import { createPortal } from 'react-dom';
function Parent() {
// This handler catches clicks from the portal!
function handleClick(e) {
console.log('Parent caught click:', e.target.tagName);
}
return (
<div onClick={handleClick}>
<h1>Parent Component</h1>
{/* This button's click bubbles through React tree */}
{createPortal(
<button>Click me (I'm in a portal)</button>,
document.getElementById('portal-root')
)}
</div>
);
}
// Clicking the button logs: "Parent caught click: BUTTON"
// Even though in the DOM, the button is NOT inside the div!
// DOM tree: React tree:
// <div id="root"> <Parent>
// <div> <div onClick={handleClick}>
// <h1>Parent</h1> <h1>Parent</h1>
// </div> <button>Click me</button>
// </div> </div>
// <div id="portal-root"> </Parent>
// <button>Click me</button>
// </div>
React events bubble through the React component tree, not the DOM tree. This is a critical distinction - a portal child's events propagate to its React parent, even though it lives in a different DOM node.
import { createPortal } from 'react-dom';
import { useState, useRef, useEffect } from 'react';
function Tooltip({ text, children }) {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
useEffect(() => {
if (show && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setCoords({
top: rect.top - 8 + window.scrollY,
left: rect.left + rect.width / 2,
});
}
}, [show]);
return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
</span>
{show && createPortal(
<div
className="tooltip"
style={{
position: 'absolute',
top: coords.top,
left: coords.left,
transform: 'translate(-50%, -100%)',
background: '#333',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '14px',
zIndex: 9999,
}}
>
{text}
</div>,
document.body
)}
</>
);
}
// Usage - works even inside overflow:hidden containers
function App() {
return (
<div style={{ overflow: 'hidden', height: 100 }}>
<Tooltip text="This tooltip is not clipped!">
<button>Hover me</button>
</Tooltip>
</div>
);
}
Portals are essential for tooltips that need to escape overflow containers. The tooltip renders on document.body and positions itself based on the trigger element's coordinates.
Portals, createPortal, Modal, Event Bubbling