Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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

index.html
<!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.

src/components/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() 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.

Wir erweitern CartPage.jsx um einen Bestätigungs-Dialog vor dem Löschen eines Artikels – ein realistischer Anwendungsfall für ein Modal:

src/pages/CartPage.jsx
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).