React Portals: Modals und Tooltips sauber aus der DOM-Hierarchie loesen
AI generated
{ }
React 19 · Portals · UI-Patterns
React Portals richtig einsetzen
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.

13 Min. Lesezeit createPortal() · React DOM Barrierefreiheit

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')
  );
}

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.

11. FAQ: React Portals fuer Modals und Tooltips: Das Wichtigste auf einen Blick

1Was ist der Unterschied zwischen createPortal und normalem JSX-Rendering?
Normales JSX rendert an der Stelle im DOM, an der die Komponente im Baum steht. createPortal rendert denselben React-Inhalt an einen beliebigen anderen DOM-Knoten, waehrend die Komponente im React-Baum weiterhin an ihrer urspruenglichen Position bleibt.
2Warum bricht ein Modal aus overflow hidden nicht einfach mit position fixed aus?
position fixed positioniert relativ zum Viewport, kann aber trotzdem vom overflow hidden eines Vorfahren abgeschnitten werden, insbesondere wenn dieser Vorfahre einen eigenen transform oder filter gesetzt hat und damit einen eigenen Stacking-Context erzeugt. Ein Portal umgeht das Problem vollstaendig, weil das Element gar nicht mehr im DOM-Teilbaum dieses Vorfahren liegt.
3Funktionieren React-Events wirklich trotz Portal wie erwartet?
Ja. React propagiert synthetische Events entlang des React-Komponentenbaums, nicht entlang der tatsaechlichen DOM-Struktur. Ein Klick innerhalb eines portalisierten Kindes bubbled deshalb weiterhin zu onClick-Handlern der logischen Elternkomponenten.
4Muss fuer jedes Portal ein eigener DOM-Knoten angelegt werden?
Nicht zwingend, mehrere Portale koennen denselben Zielknoten wie document.body teilen. Fuer eine saubere Trennung von Stacking-Contexts, etwa zwischen Modal, Toast und Tooltip, empfiehlt sich aber jeweils ein eigener dedizierter Root-Knoten.
5Wie funktioniert createPortal beim Server-Side-Rendering?
Auf dem Server existiert kein document-Objekt, daher darf createPortal dort nicht ausgefuehrt werden. Ueblich ist, das Portal erst nach dem Mounten im Browser zu rendern, gesteuert ueber einen isMounted-Status, der in useEffect gesetzt wird.
6Wie verhindere ich, dass der Portal-Root bei jedem Render neu erzeugt wird?
Der DOM-Knoten sollte einmalig erzeugt und ueber eine Ref oder einen eigenen Hook stabil gehalten werden, statt ihn bei jedem Render neu per document.createElement zu erstellen. Sonst entstehen unnoetige DOM-Mutationen und potenzielle Speicherlecks.
7Wie sorge ich fuer korrekte Tastaturbedienbarkeit in einem Portal-Modal?
Beim Oeffnen den Fokus programmatisch auf das Modal setzen, die Tab-Navigation innerhalb des Modals per Fokus-Falle halten, auf Escape das Modal schliessen, und beim Schliessen den Fokus zurueck auf das ausloesende Element setzen.
8Kann ein Tooltip auch ohne Portal positioniert werden?
Technisch ja, solange kein Vorfahre overflow hidden oder einen begrenzenden Stacking-Context setzt. In der Praxis ist das selten garantiert, weshalb Portale die robustere Standardloesung fuer Tooltips in komplexen Layouts sind.
9Was passiert mit CSS, das auf Elternselektoren basiert, wenn ich ein Kind per Portal auslagere?
Solche CSS-Selektoren greifen nicht mehr, weil das Kind im DOM nicht laenger ein Nachfahre dieses Elternelements ist. Styles muessen deshalb direkt am portalisierten Element oder ueber globale Klassen und CSS-Variablen statt ueber Vererbung von Vorfahren gesetzt werden.
10Lohnt sich eine eigene Portal-Bibliothek oder reicht die native API?
Fuer einfache Faelle reicht createPortal zusammen mit einem eigenen Hook fuer Root-Erzeugung und Cleanup voellig aus. Bei komplexer Positionierungslogik mit Kollisionserkennung und automatischem Ausrichten lohnt sich eine spezialisierte Bibliothek wie Floating UI.