Portals: Modals sauber bauen in React
Portals: Modals sauber bauen
~13 Min. Lesezeit Zuletzt aktualisiert am 8. August 2026
Ein Modal (Dialog-Overlay) muss sich VISUELL über die gesamte App legen – aber in React entsteht es normalerweise TIEF verschachtelt im Komponentenbaum, z. B. innerhalb einer einzelnen ProductCard. Portals lösen genau diesen Widerspruch.
Das eigentliche Problem: CSS, nicht React
Würde man ein Modal normal, tief verschachtelt rendern, könnte es von overflow: hidden, z-index-Konflikten oder transform-Eigenschaften eines Vorfahren-Elements beschnitten oder falsch positioniert werden – CSS-Stacking-Contexts sind an die tatsächliche DOM-Hierarchie gebunden, nicht an die React-Komponentenhierarchie. Ein Modal, das visuell auf oberster Ebene erscheinen soll, MUSS im echten DOM auch auf oberster Ebene sitzen.
Was createPortal wirklich tut
ReactDOM.createPortal(children, domNode) rendert children in einen BELIEBIGEN DOM-Knoten – nicht in den Knoten, der der aktuellen Position im React-Baum entspricht. Entscheidend: Auch wenn das Modal woanders im DOM landet, bleibt es im REACT-Baum an seiner ursprünglichen Stelle – Events "bubbeln" weiterhin durch die React-Hierarchie (nicht die DOM-Hierarchie!), Context-Werte bleiben verfügbar, alles verhält sich wie normal, NUR die visuelle DOM-Position ändert sich.
Den Ziel-Knoten in index.html anlegen
<!doctype html>
<html lang="de">
<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"> ist ein GESCHWISTER-Element von #root, nicht sein Kind – dadurch beeinflussen CSS-Eigenschaften irgendeiner Komponente INNERHALB von #root (z. B. overflow: hidden am App-Container) das Modal überhaupt nicht mehr.
Modal.jsx anlegen
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() am modal-content-div verhindert, dass ein Klick INNERHALB des Modals das onClick des Backdrops auslöst (das Modal schließen würde) – dasselbe Bubbling-Prinzip, das wir bereits bei ProductCards Favoriten-Button in "React für Einsteiger" kennengelernt haben.
Praxis: ein Bestätigungs-Dialog beim Löschen aus dem Warenkorb
Wir erweitern CartPage.jsx um einen Bestätigungs-Dialog vor dem Löschen eines Artikels – ein realistischer Anwendungsfall für ein 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>Ihr Warenkorb ist leer.</p>;
}
return (
<div>
<h2>Ihr Warenkorb</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)}>Entfernen</button>
</li>
))}
</ul>
<p><strong>Gesamt: ${total.toFixed(2)}</strong></p>
<button onClick={() => dispatch(clearCart())}>Warenkorb leeren</button>
<Modal isOpen={itemToRemove !== null} onClose={() => setItemToRemove(null)}>
<h3>Artikel entfernen?</h3>
<p>
Möchten Sie "{itemToRemove?.name}" wirklich aus dem Warenkorb entfernen?
</p>
<button onClick={confirmRemoval}>Ja, entfernen</button>
<button onClick={() => setItemToRemove(null)}>Abbrechen</button>
</Modal>
</div>
);
}
export default CartPage;itemToRemove?.name (Optional Chaining) ist nötig, weil itemToRemove null ist, solange kein Löschen angefragt wurde – Modal selbst prüft zwar isOpen, ABER React wertet {{children}} (inklusive {{itemToRemove?.name}}) trotzdem AUS, bevor Modal entscheidet, ob es überhaupt etwas rendert.
Beweis: DOM-Position vs. React-Baum-Position
Öffnen Sie den Bestätigungs-Dialog und inspizieren Sie das echte DOM (Browser-DevTools, "Elements"-Tab): Sie werden sehen, dass .modal-backdrop als direktes Kind von <div id="modal-root"> erscheint, KOMPLETT getrennt von <div id="root">. Wechseln Sie zum React-DevTools "Components"-Tab: dort erscheint Modal weiterhin an seiner LOGISCHEN Stelle, als Kind von CartPage – genau der Unterschied, den Portals ermöglichen.
Tipp: Weitere typische Portal-Anwendungsfälle über Modals hinaus: Tooltips (müssen über allem anderen schweben), Toast-Benachrichtigungen (fixe Position, unabhängig vom auslösenden Element), Dropdown-Menüs, die den sichtbaren Bereich eines overflow: hidden-Containers verlassen müssen (siehe das Material-Design-Menü-Thema in der "React Native Referenz"-Serie – dort mit Modal in React Native gelöst, hier mit Portals das Web-Äquivalent).