What is portal in React
React Portal renders children into a DOM node outside the parent component's hierarchy while keeping them connected to React's virtual DOM tree, state, and event system.
Theory
TL;DR
- Portal is like mailing a letter: your JSX gets delivered to a different DOM address, but stays connected to the sender for updates and events.
- Main difference: it bypasses parent CSS (z-index, overflow:hidden) without losing React state or context.
- React keeps the fiber node under the calling component, so hooks, effects, and context flow normally.
- Use when a child needs to escape CSS clipping or stacking. Skip for normal nesting.
Quick example
import { createPortal } from 'react-dom';
import { useState } from 'react';
function DropdownMenu() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Menu</button>
{open && createPortal(
<div style={{ position: 'absolute', background: 'white', border: '1px solid #ccc' }}>
<button onClick={() => setOpen(false)}>Close</button>
</div>,
document.getElementById('dropdown-root') // renders outside #root
)}
</>
);
}The dropdown appears above the clipped parent, state stays synced, and click events still bubble through the React tree.
Key difference from regular rendering
Portals detach only the final DOM output from the parent subtree. React still keeps the fiber node under the calling component, so state, effects, and context providers all work as expected. The only thing that changes is where the final paint happens in the DOM.
This is why a modal inside a transform: rotateX(10deg) container gets displaced or clipped. The CSS stacking context traps it. A portal escapes that trap entirely.
When to use
- Modal over a parent with
z-index: 10- portal tobody, render atz-index: 9999. - Tooltip inside a CSS-transformed container - portal escapes transform origin limits.
- Dropdown in an
overflow: hiddentable cell - portal todocument.bodyavoids clipping. - Notification stack at screen edge - portal keeps it independent from layout flow.
Skip portals when the child fits within parent styles or needs relative positioning to its parent.
How it works internally
React's reconciler creates a fiber for the portal child under the calling component but sets its containerInfo to the target DOM node. During the commit phase, DOM mutations go to that target via container.appendChild. Events still proxy through React's synthetic event system regardless of DOM location, so onClick bubbles through the React tree, not the DOM tree.
The SSR mismatch tends to trip up teams more than the z-index problem. The z-index issue is visible immediately. The hydration error shows up later, in production.
Common mistakes
Portaling to a node that doesn't exist:
createPortal(<div>Hi</div>, document.getElementById('missing')); // null - throws in React 18Fix: create the element inside a useEffect and append it to document.body.
Forgetting that DOM parent differs from React parent:
document.getElementById('modal-root').children[0].parentNode
=== document.getElementById('app-root'); // falseDOM hierarchy and React hierarchy are separate. Traversing the DOM won't reflect the component tree structure.
SSR hydration mismatch:
// Server has no #portal-root, client tries to portal - React 18 throws
createPortal(<div>content</div>, document.getElementById('portal-root'));Fix: guard with a mounted state set inside useEffect. Return a hidden placeholder server-side.
No cleanup on unmount:
If you manually create a container element in useEffect, remove it in the cleanup function. Otherwise DOM nodes pile up across re-mounts.
Real-world usage
- Material-UI (v5) - modals and dropdowns portal to
document.bodyfor stacking context. - Chakra UI -
DefaultPortalManagerappends to a custom#chakra-portal-root. - Headless UI - Menu and Combobox portal for focus trap outside parent.
- React Bootstrap - Popover and Tooltip use portals to escape table cells.
Follow-up questions
Q: Do portals preserve React context from parent providers?
A: Yes. Context reads from the React fiber tree, not the DOM tree. A portal child sees all ancestor providers even though it renders into a different DOM node.
Q: How does event bubbling work with portals?
A: Native DOM events bubble through the DOM. React's synthetic events proxy through the React root. So onClick on a portal element bubbles through its React ancestors, not its DOM ancestors.
Q: What's the difference between a portal and Shadow DOM?
A: Portals keep global styles and React events connected. Shadow DOM creates a fully isolated style and event boundary. Use Shadow DOM for true third-party widget isolation; use portals to escape CSS constraints while staying in the React tree.
Q: How do you handle portals in SSR (Next.js)?
A: Use a mounted state flag set in useEffect. Return a hidden placeholder during server render. This prevents hydration mismatch errors in React 18 strict mode.
Examples
Confirmation modal with scroll lock
import { createPortal } from 'react-dom';
import { useEffect } from 'react';
function ConfirmationModal({ isOpen, onConfirm, onCancel, message }) {
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden'; // lock scroll
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999 }}>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }}>
<p>{message}</p>
<button onClick={onConfirm}>Confirm</button>
<button onClick={onCancel}>Cancel</button>
</div>
</div>,
document.body
);
}The modal renders directly on document.body, so no parent overflow, transform, or z-index can clip it. Scroll lock prevents background scrolling while the modal is open. This is the same pattern used in react-modal and Material-UI Dialog.
SSR-safe portal
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
function ClientOnlyPortal({ children }) {
const [mounted, setMounted] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
containerRef.current = document.getElementById('portal-root');
setMounted(true);
}, []);
if (!mounted) return <div style={{ visibility: 'hidden' }}>{children}</div>;
return createPortal(children, containerRef.current);
}Without the mounted guard, the server renders one thing and the client another. React 18 throws a hydration error. The hidden placeholder keeps DOM structure consistent across server and client, so React can reconcile without complaints.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.