Portals: Building Modals Cleanly in React
Portals: Building Modals Cleanly
~13 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
A modal (dialog overlay) needs to VISUALLY sit on top of the entire app – but in React it typically gets created DEEP inside the component tree, e.g. within a single ProductCard. Portals solve exactly this contradiction.
The real problem: CSS, not React
If you rendered a modal normally, deeply nested, it could get clipped or mispositioned by overflow: hidden, z-index conflicts, or transform properties on an ancestor element – CSS stacking contexts are tied to the actual DOM hierarchy, not the React component hierarchy. A modal meant to visually appear at the very top level MUST also sit at the top level in the real DOM.
What createPortal really does
ReactDOM.createPortal(children, domNode) renders children into ANY DOM node you choose – not the node that corresponds to the current position in the React tree. Crucially: even though the modal ends up elsewhere in the DOM, it stays in its original spot in the REACT tree – events still "bubble" through the React hierarchy (not the DOM hierarchy!), context values remain available, everything behaves normally, ONLY the visual DOM position changes.
Creating the target node in index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Product Catalog</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html><div id="modal-root"> is a SIBLING element of #root, not its child – that way, CSS properties on any component INSIDE #root (e.g. overflow: hidden on the app container) no longer affect the modal at all.
Creating Modal.jsx
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) {
return null;
}
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
✕
</button>
{children}
</div>
</div>,
document.getElementById('modal-root')
);
}
export default Modal;event.stopPropagation() on the modal-content div prevents a click INSIDE the modal from triggering the backdrop's onClick (which would close it) – the same bubbling principle we already learned about with ProductCard's favorite button in "React for Beginners".
In practice: a confirmation dialog for removing a cart item
We'll extend CartPage.jsx with a confirmation dialog before removing an item – a realistic use case for a modal:
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { removeItem, updateQuantity, clearCart } from '../store/cartSlice';
import Modal from '../components/Modal';
function CartPage() {
const items = useSelector((state) => state.cart.items);
const dispatch = useDispatch();
const [itemToRemove, setItemToRemove] = useState(null);
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
function confirmRemoval() {
dispatch(removeItem(itemToRemove.sku));
setItemToRemove(null);
}
if (items.length === 0) {
return <p>Your cart is empty.</p>;
}
return (
<div>
<h2>Your Cart</h2>
<ul>
{items.map((item) => (
<li key={item.sku}>
{item.name} – ${item.price.toFixed(2)} ×{' '}
<input
type="number"
min="1"
value={item.quantity}
onChange={(event) =>
dispatch(updateQuantity({ sku: item.sku, quantity: Number(event.target.value) }))
}
/>
<button onClick={() => setItemToRemove(item)}>Remove</button>
</li>
))}
</ul>
<p><strong>Total: ${total.toFixed(2)}</strong></p>
<button onClick={() => dispatch(clearCart())}>Clear Cart</button>
<Modal isOpen={itemToRemove !== null} onClose={() => setItemToRemove(null)}>
<h3>Remove item?</h3>
<p>
Are you sure you want to remove "{itemToRemove?.name}" from your cart?
</p>
<button onClick={confirmRemoval}>Yes, remove</button>
<button onClick={() => setItemToRemove(null)}>Cancel</button>
</Modal>
</div>
);
}
export default CartPage;itemToRemove?.name (optional chaining) is needed because itemToRemove is null as long as no removal has been requested – Modal itself does check isOpen, BUT React still EVALUATES {{children}} (including {{itemToRemove?.name}}) before Modal decides whether to render anything at all.
Proof: DOM position vs. React tree position
Open the confirmation dialog and inspect the real DOM (browser DevTools, "Elements" tab): you'll see .modal-backdrop appear as a direct child of <div id="modal-root">, COMPLETELY separate from <div id="root">. Switch to the React DevTools "Components" tab: there, Modal still appears at its LOGICAL spot, as a child of CartPage – exactly the difference portals make possible.
Tipp: Other typical portal use cases beyond modals: tooltips (need to float above everything else), toast notifications (fixed position, independent of the triggering element), dropdown menus that need to escape the visible area of an overflow: hidden container (see the Material Design menu topic in the "React Native Reference" series – solved there with Modal in React Native, here with portals as the web equivalent).