Modals und Tooltips ausserhalb der DOM-Hierarchie, aber weiterhin im React-Baum verankert
Ein Modal, das in einer tief verschachtelten Komponente liegt, wird von overflow: hidden eines Vorfahren oft sichtbar abgeschnitten. createPortal() rendert es an einen anderen DOM-Knoten, ohne dass es im React-Baum seinen logischen Platz fuer Events und Context verliert.
Inhaltsverzeichnis
- 1. Warum ueberhaupt Portals
- 2. createPortal() im Detail
- 3. Event-Bubbling folgt dem React-Baum, nicht dem DOM
- 4. Context bleibt trotz Portal wirksam
- 5. Ein vollstaendiges Modal mit Portal
- 6. Tooltips und Dropdowns positionieren
- 7. Mehrere Portale und SSR-Faelle verwalten
- 8. Barrierefreiheit bei portalisierten Komponenten
- 9. Haeufige Fehler beim Einsatz von Portals
- 10. Zusammenfassung
- 11. FAQ
1. Warum ueberhaupt Portals
Ein Modal, das tief in einer Produktkarte oder einer Sidebar-Komponente verschachtelt liegt, erbt automatisch die CSS-Eigenschaften seiner Vorfahren. Setzt irgendein Elternelement overflow: hidden, um beispielsweise eine Bildergalerie sauber zu begrenzen, wird das Modal an genau dieser Kante abgeschnitten, obwohl es visuell den ganzen Bildschirm ueberdecken soll. Aehnlich verhaelt es sich mit z-index: Ein Vorfahre mit eigenem Stacking-Context (etwa durch transform oder will-change) kann dazu fuehren, dass ein noch so hoher z-index-Wert am Modal wirkungslos bleibt, weil der Vergleich nur innerhalb desselben Stacking-Contexts stattfindet.
React trennt konzeptionell zwei Baeume voneinander: den React-Komponentenbaum, der bestimmt, wie Props, State und Context fliessen, und den tatsaechlichen DOM-Baum, in dem die Elemente physisch liegen. Im Normalfall sind beide Baeume deckungsgleich, ein Kind im JSX landet auch im DOM als Kind desselben Elements. Portals loesen genau diese Kopplung auf: Der Inhalt bleibt an seiner logischen Stelle im React-Baum, wird aber an eine frei waehlbare Stelle im DOM gerendert, typischerweise ausserhalb aller begrenzenden Container.
2. createPortal() im Detail
Die Funktion createPortal(children, domNode) aus dem Paket react-dom nimmt zwei Argumente entgegen: den zu rendernden React-Inhalt und den Ziel-DOM-Knoten, in den dieser Inhalt eingehaengt werden soll. Der Rueckgabewert ist ein ganz normales React-Element, das an beliebiger Stelle im JSX einer Komponente zurueckgegeben werden kann, etwa direkt aus einer return-Anweisung heraus. Fuer React selbst aendert sich dadurch nichts an Lifecycle, Reconciliation oder der Reihenfolge der Hooks, nur der physische Renderort im DOM unterscheidet sich vom logischen Ort im Komponentenbaum.
Der Zielknoten muss existieren, bevor das Portal gerendert wird. In vielen Projekten reicht dafuer ein zusaetzlicher div in der HTML-Grundstruktur, etwa neben dem Haupt-Root-Element. Alternativ laesst sich der Knoten dynamisch per document.createElement erzeugen und in document.body einhaengen, was sich gut mit einem eigenen Hook kapseln laesst, der Erzeugung und Aufraeumen beim Unmount uebernimmt.
import { createPortal } from 'react-dom';
function Tooltip({ children }) {
const portalRoot = document.getElementById('portal-root');
// children wird visuell an portalRoot gerendert,
// bleibt aber im React-Baum an dieser Stelle verankert.
return createPortal(
<div className="tooltip">{children}</div>,
portalRoot
);
}
3. Event-Bubbling folgt dem React-Baum, nicht dem DOM
Ein haeufiges Missverstaendnis lautet, ein per Portal ausgelagertes Element sei fuer Events komplett isoliert, weil es DOM-technisch woanders liegt. Tatsaechlich propagiert React seine synthetischen Events entlang des React-Komponentenbaums und nicht entlang der tatsaechlichen DOM-Struktur. Ein Klick innerhalb eines portalisierten Kindes bubbled deshalb weiterhin zu onClick-Handlern der logischen Elternkomponenten, exakt so, als waere gar kein Portal im Spiel.
Praktisch relevant wird das etwa bei einem Dropdown-Menue, dessen Optionsliste per Portal ausgelagert ist, um nicht von einem overflow: hidden der Navigationsleiste abgeschnitten zu werden. Ein onClick-Handler am aeusseren Wrapper, der das Menue bei einem Klick ausserhalb schliessen soll, muss trotzdem beruecksichtigen, dass Klicks innerhalb des Portal-Inhalts im React-Sinn weiterhin als Klicks innerhalb der Komponente gelten.
function Dropdown() {
const [open, setOpen] = useState(false);
// Klick im Portal-Inhalt bubbled bis hierher,
// obwohl das Menue DOM-technisch in portalRoot liegt.
return (
<div onClick={() => setOpen(false)}>
<button onClick={(e) => { e.stopPropagation(); setOpen(true); }}>
Menue oeffnen
</button>
{open && createPortal(
<ul className="dropdown-list">
<li>Option A</li>
<li>Option B</li>
</ul>,
document.getElementById('portal-root')
)}
</div>
);
}
4. Context bleibt trotz Portal wirksam
Ein Context-Provider haengt am React-Baum, nicht am physischen DOM-Ort seiner Konsumenten. Deshalb funktioniert useContext innerhalb eines portalisierten Kindes weiterhin zuverlaessig, obwohl dieses Kind DOM-technisch ausserhalb des Providers liegen kann. React loest den Wert ueber die Position im Komponentenbaum auf, und diese Position bleibt durch das Portal unveraendert.
Praktisch ist das besonders bei Theme-Context, Internationalisierungs-Context oder Authentifizierungs-Context in Modals wichtig. Ohne diese Eigenschaft muesste jeder Provider erneut rund um den Portal-Zielknoten aufgebaut werden, was Boilerplate erzeugt und leicht zu inkonsistenten Werten fuehrt, wenn ein Provider vergessen wird. Mit Portals reicht der bereits bestehende Provider im logischen Baum vollkommen aus.
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <Modal />; // Modal liegt logisch weiter innen im Provider
}
function Modal() {
const theme = useContext(ThemeContext); // liefert weiterhin 'dark',
// obwohl das Portal ausserhalb des Provider-DOM-Subtrees rendert
return createPortal(
<div className={`modal modal--${theme}`}>Inhalt</div>,
document.getElementById('portal-root')
);
}
5. Ein vollstaendiges Modal mit Portal
Ein produktives Modal braucht mehr als nur createPortal: Beim Oeffnen sollte der Fokus programmatisch auf das Modal oder ein enthaltenes Element gesetzt werden, damit Tastaturnutzer sofort dort landen. Ein keydown-Listener auf Escape schliesst das Modal, und dieser Listener muss beim Unmount wieder entfernt werden, um Speicherlecks und doppelte Handler bei mehrfach geoeffneten Modals zu vermeiden.
Waehrend das Modal offen ist, sollte der Hintergrund nicht mehr scrollbar sein. Ueblich ist dafuer, overflow: hidden direkt auf document.body zu setzen und beim Schliessen wieder zu entfernen. Wird dieser Schritt vergessen, laesst sich der Hintergrund unter dem Modal weiterscrollen, was auf Touch-Geraeten besonders stoerend auffaellt.
function Modal({ onClose, children }) {
const ref = useRef(null);
useEffect(() => {
ref.current?.focus();
document.body.style.overflow = 'hidden';
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.getElementById('portal-root')
);
}
6. Tooltips und Dropdowns positionieren
Bei Tooltips und Dropdowns kommt zur reinen DOM-Auslagerung noch eine dynamische Positionierung hinzu. Ueblicherweise wird die Position des Ausloese-Elements per getBoundingClientRect() ermittelt und daraus die top/left-Koordinaten des portalisierten Inhalts berechnet. Diese Berechnung sollte synchron vor dem sichtbaren Frame erfolgen, sonst erscheint der Tooltip kurz an der falschen Stelle und springt sichtbar an die korrekte Position.
Randfaelle wie eine Viewport-Kollision muessen zusaetzlich behandelt werden: Reicht der Platz unterhalb des Trigger-Elements nicht aus, sollte der Tooltip automatisch oberhalb erscheinen. Fuer einfache Faelle laesst sich das mit ein paar Zeilen eigener Logik loesen, bei komplexeren Anforderungen (automatisches Ausrichten an mehreren Achsen, Kollisionserkennung mit Scroll-Containern) lohnt sich eine spezialisierte Bibliothek wie Floating UI.
function Tooltip({ triggerRef, children }) {
const [pos, setPos] = useState(null);
useLayoutEffect(() => {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 8, left: rect.left });
}, [triggerRef]);
if (!pos) return null;
return createPortal(
<div
className="tooltip"
style={{ position: 'fixed', top: pos.top, left: pos.left }}
>
{children}
</div>,
document.getElementById('portal-root')
);
}
7. Mehrere Portale und SSR-Faelle verwalten
Sobald mehrere Portal-Typen gleichzeitig existieren, etwa ein Modal, mehrere Toast-Benachrichtigungen und ein Tooltip, empfiehlt sich fuer jeden Typ ein eigener dedizierter Root-Knoten im DOM. Das trennt die jeweiligen Stacking-Contexts sauber voneinander und verhindert, dass beispielsweise ein Toast versehentlich hinter einem Modal-Overlay verschwindet, nur weil beide denselben Container teilen.
Auf dem Server existiert kein document-Objekt, weshalb createPortal dort nicht ausgefuehrt werden darf. Die uebliche Loesung ist ein isMounted-Flag, das erst in einem useEffect nach dem ersten Client-Render auf true gesetzt wird. Erst dann wird das Portal tatsaechlich gerendert, waehrend der Server lediglich eine leere Huelle ausliefert.
function usePortalRoot(id) {
const [node, setNode] = useState(null);
useEffect(() => {
let el = document.getElementById(id);
let created = false;
if (!el) {
el = document.createElement('div');
el.id = id;
document.body.appendChild(el);
created = true;
}
setNode(el);
return () => {
if (created) el.remove();
};
}, [id]);
return node;
}
8. Barrierefreiheit bei portalisierten Komponenten
Ein Modal braucht semantisch korrekte ARIA-Attribute: role="dialog" und aria-modal="true" signalisieren Screenreadern, dass der restliche Seiteninhalt waehrend der Interaktion nicht relevant ist, aria-labelledby verweist auf die ID der Modal-Ueberschrift, damit der Titel beim Fokussieren vorgelesen wird. Eine Fokus-Falle haelt die Tab-Navigation innerhalb des Modals, sodass Tastaturnutzer nicht versehentlich auf Elemente im Hintergrund springen.
Beim Schliessen des Modals muss der Fokus explizit zurueck auf das Element gesetzt werden, das das Modal urspruenglich geoeffnet hat. Ohne diesen Schritt landet der Fokus oft einfach auf document.body, was fuer Tastatur- und Screenreader-Nutzer wie ein ploetzlicher Kontextverlust wirkt und die Orientierung auf der Seite erschwert.
function useFocusTrap(ref, active) {
useEffect(() => {
if (!active) return;
const node = ref.current;
const focusable = node.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const handleTab = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
node.addEventListener('keydown', handleTab);
return () => node.removeEventListener('keydown', handleTab);
}, [ref, active]);
}
9. Haeufige Fehler beim Einsatz von Portals
Der haeufigste Fehler ist, den Portal-Zielknoten bei jedem Render neu per document.createElement zu erzeugen, statt ihn einmalig zu erstellen und ueber eine Ref oder einen Hook stabil zu halten. Das fuehrt zu unnoetigen DOM-Mutationen, sichtbarem Flackern und im schlimmsten Fall zu Speicherlecks, wenn alte Knoten nie wieder entfernt werden.
Ein zweites verbreitetes Problem sind Z-Index-Kollisionen zwischen mehreren gleichzeitig offenen Portalen, wenn nicht klar geregelt ist, welche Ebene ueber welcher liegt. Dazu kommt oft fehlendes Cleanup von Event-Listenern fuer Resize- oder Scroll-Events, die zur Positionsberechnung genutzt werden, aber beim Unmount der Komponente nicht entfernt werden und danach still weiterlaufen.
| Ansatz | Loest Overflow-Problem | Event-Bubbling im React-Baum | Empfehlung |
|---|---|---|---|
z-index-Hack ohne Portal |
Nein, bleibt im Stacking-Context des Vorfahren | Ja, unveraendert | Nur bei sehr einfachem, kontrolliertem Layout |
position: fixed ohne Portal |
Teilweise, scheitert bei Vorfahren mit eigenem Stacking-Context | Ja, unveraendert | Nicht empfohlen fuer verschachtelte Layouts |
createPortal zu document.body |
Ja, vollstaendig | Ja, folgt dem React-Baum | Guter Standardfall fuer einzelne Modals |
createPortal zu eigenem Root-Knoten |
Ja, vollstaendig, plus saubere Stacking-Context-Trennung | Ja, folgt dem React-Baum | Empfohlen bei mehreren gleichzeitigen Portal-Typen |
Mironsoft
React-Architektur, Performance und Magento-Frontend-Integration
React-Frontends, die schnell bleiben statt mit jedem Feature langsamer zu werden?
Wir prüfen bestehende React-Anwendungen auf unnötige Re-Renders, aufgeblähte Bundles und fragile State-Verwaltung und bauen daraus ein Frontend, das performant bleibt und sich sauber an Magento oder andere Backends anbindet.
Performance-Audit
Re-Renders, Bundle-Größe und Ladezeiten systematisch messen und beheben.
State-Architektur
Context, Zustand und Server State sauber trennen statt alles in einen Topf zu werfen.
Magento-Integration
GraphQL- oder REST-Anbindung an Magento robust und typsicher aufbauen.
10. Zusammenfassung
React Portals fuer Modals und Tooltips: Das Wichtigste auf einen Blick
Portal-API
createPortal(children, domNode) rendert React-Kinder in einen beliebigen DOM-Knoten, unabhaengig vom umgebenden Layout.
Event-Bubbling
Events folgen dem React-Baum, nicht dem DOM-Baum, Klicks im Portal erreichen weiterhin die logischen Eltern-Handler.
Context
Context-Provider bleiben wirksam, weil sie am React-Baum haengen und nicht am physischen DOM-Ort.
Barrierefreiheit
Fokus-Falle, aria-modal und Escape-Handling gehoeren zu jedem produktiven Portal-Modal dazu.