Startseite / Artikel / Reaktive Modal-Fenster ohne Neuladen der Seite: Stack Store und Portal Manager

Reaktive Modal-Fenster ohne Neuladen der Seite: Stack Store und Portal Manager

Bewahren Sie den Modal-Zustand in einem Zustandsstapel außerhalb des Baums auf, rendern Sie ihn mit einem sibling ModalManager und Portalen, und synchronisieren Sie verschachtelte Dialoge über pushState mit der URL.

2162 Wörter

TL;DR – Halten Sie den Modal-Zustand außerhalb des React-Component-Baums. Speichern Sie ihn als Stack in einem externen Store (hier Zustand), rendern Sie ihn über einen dedizierten ModalManager, der als Bruderknoten der Anwendung montiert wird (nicht als Vorfahrenknoten), und laden Sie die Modal-Inhalte über ein Portal mit lazy(). Dadurch wird die darunterliegende Seite beim Öffnen, Schließen oder Nesten von Dialogen niemals neu gerendert; das Nesten erfolgt einfach durch Hinzufügen in ein Array; und die URL bleibt dank pushState / popstate korrekt.

Ein gängiges erstes Modal-System in React sieht so aus:

function App() {
 const [modal, setModal] = useState(null);
 // …rest of the app tree lives here
}

Es funktioniert – bis es plötzlich nicht mehr funktioniert. Sobald der modal-Zustand auf einem Vorfahrenknoten der Seite liegt, führt jedes Öffnen oder Schließen dazu, dass der gesamte Unterbaum neu gerendert wird. Auf einem einfachen Bildschirm bemerkt das niemand. Bei Diagrammen, virtualisierten Listen oder anderen aufwendigen Benutzeroberflächen kann das Tippen auf „Teilen“ zu Verzögerungen führen.

Fügen Sie zwei weitere Anforderungen hinzu – Dialoge, die weitere Dialoge öffnen (Teilen → Kommentare → Antworten) sowie eine URL, die das Aktuelle widerspiegelt (Erneuern, Zurück, Deep Links) – und ein einzelnes useState ist dann nicht mehr nur verschwenderisch, sondern auch schwer verständlich.

Die unten dargestellte Architektur isoliert diese Probleme.

Die Kernidee: Den Modal-Zustand aus dem Render-Tree entfernen

Die Kosten durch erneutes Rendering hängen davon ab, wo der Zustand gespeichert ist. Modal-Flags, die von einem Vorfahren verwaltet werden, zwingen den Unterbaum dieses Vorfahren dazu, bei jeder Änderung aktualisiert zu werden.

// modalStore.ts
import { create } from 'zustand';

export interface ModalEntry {
  id: string;
  key: string;
  props?: Record<string, unknown>;
  urlParams?: Record<string, string>;
}

interface ModalState {
  stack: ModalEntry[];
  push: (entry: Omit<ModalEntry, 'id'> & { id?: string }) => string;
  pop: () => void;
  popById: (id: string) => void;
  closeAll: () => void;
  setStack: (stack: ModalEntry[]) => void;
}

let counter = 0;
const nextId = () => `modal_${Date.now()}_${counter++}`;

export const useModalStore = create<ModalState>((set, get) => ({
  stack: [],
  push: (entry) => {
    const id = entry.id ?? nextId();
    set((s) => ({ stack: [...s.stack, { ...entry, id }] }));
    return id;
  },
  pop: () => set((s) => ({ stack: s.stack.slice(0, -1) })),
  popById: (id) => set((s) => ({ stack: s.stack.filter((m) => m.id !== id) })),
  closeAll: () => set({ stack: [] }),
  setStack: (stack) => set({ stack }),
}));

Zwei Aspekte sind wichtig. Erstens handelt es sich um einen Stack, nicht um eine einzelne Spalte – das macht die Verkettung möglich. Zweitens handelt es sich um einen Zustand-Speicher, nicht um React Context. Context informiert alle Nutzer; Zustand ermöglicht es einem Komponenten, einen bestimmten Teil auszuwählen, sodass nur dieser Abonnent neu gerendert wird. Bei der durchgängigen Frage „Was ist geöffnet?“ liegt genau in diesem Unterschied der Schlüssel.

Das Öffnen eines Modals sollte den React-Zustand nicht direkt beeinflussen

Die Hilfsfunktionen zum Öffnen und Schließen lesen und schreiben den Speicher über getState(), nicht über useModalStore():

// modalActions.ts
export function openModal(key: string, params: Record<string, string>) {
  const meta = modalRegistry[key];
  if (!meta) return;
  useModalStore.getState().push({
    key,
    urlParams: params,
    props: meta.fromParams(params),
  });
}

export function closeModal(id: string) {
  useModalStore.getState().popById(id);
}

Weil openModal den Hook niemals aufruft, führt seine Ausführung weder zu einer Auswahl eines Stores noch zur erneuten Darstellung von Inhalten – nur die Aktualisierung des Stores bewirkt das, und nur die Komponenten, die an den Store gebunden sind, spüren dies. Rufen Sie openModal(...) von jedem Klick-Handler auf – auch von innerhalb eines anderen Modals –, dann ist der einzige Reaktor die Komponente, deren Aufgabe es ist zu reagieren.

Die einzige Komponente, die sich darum kümmern darf

// ModalManager.tsx
export function ModalManager() {
  const stack = useModalStore((s) => s.stack);
  const root = usePortalRoot('modal-root');

  if (!root || stack.length === 0) return null;

  return createPortal(
    <>
      {stack.map((entry, index) => {
        const meta = modalRegistry[entry.key];
        if (!meta) return null;
        const Component = meta.component;
        const isTop = index === stack.length - 1;
        const Shell = meta.kind === 'sheet' ? BottomSheetShell : ModalShell;

        return (
          <Shell key={entry.id} depth={index} isTop={isTop} onClose={() => closeModal(entry.id)}>
            <Suspense fallback={<div className="modal-loading">Loading…</div>}>
              <Component {...entry.props} onClose={() => closeModal(entry.id)} />
            </Suspense>
          </Shell>
        );
      })}
    </>,
    root
  );
}

ModalManager wird einmal neben <App /> eingebunden, nicht darin:

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
    <ModalManager />
    <ModalUrlSync />
  </StrictMode>,
);

Diese Geschwisterbeziehung bestimmt das Design. Wenn ModalManager <App /> umschließen würde, würde jede Änderung der Stack-Struktur den Manager sowie seine Kinder – einschließlich der App – erneut darstellen. Als Geschwister unter einem gemeinsamen Wurzelelement hört App niemals von der Aktualisierung.

Gestapelte Modale sind einfach ein längerer Array

Sobald der Zustand als Stack fungiert, ist „Modal innerhalb eines Modals“ kein Sonderfall – es handelt sich dabei um das Standardverhalten von push.

Ein Teildialog kann ein Kommentarfeld öffnen, das wiederum einen Antwortdialog öffnen kann; jeweils wird ein weiterer Eintrag hinzugefügt:

// Inside ShareModal
<button onClick={() => openModal('comments', { productId })}>
  View comments
</button>
// Inside CommentSheet
<button onClick={() => openModal('reply', { commentId })}>
  Reply
</button>

Jede Schicht erhält ihre eigene Depth (für z-index) und wird über ihre eigene ID geschlossen. Das Schließen der Antwort lässt die Kommentare und den Teildialog unverändert. Es werden weder rekursive Modal-Komponenten noch eigens entwickelte Zustandsmaschinen verwendet – lediglich ein Array mit drei Elementen.

Beibehaltung eines einfachen und gememorisierten „Shells“

Der Modal-Shell – Überlagerung, Karte, Animation, Umgang mit Escape-Taste – bleibt getrennt vom Inhalt und wird mit React.memo umhüllt:

export const ModalShell = memo(function ModalShell({ depth, isTop, onClose, children }) {
  useEffect(() => {
    if (!isTop) return; // only the topmost modal reacts to Escape
    const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [isTop, onClose]);

  return (
    <div className="modal-overlay" style={{ zIndex: 1000 + depth }} onMouseDown={/* close on backdrop click */}>
      <div className="modal-card" role="dialog" aria-modal="true">
        <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
        {children}
      </div>
    </div>
  );
});

Nur die oberste Schicht hört auf die Escape-Taste; andernfalls würde eine Tastenbetätigung versuchen, alle Ebenen zu schließen. Da die Schicht inhaltsspezifisch ist, wird jeder Inhalt mit lazy() pro Registrierungseintrag geladen, damit ein seltener „Antwort“-Dialog das ursprüngliche Paket nicht vergrößert:

export const modalRegistry: Record<string, ModalRegistryItem> = {
  share: { component: lazy(() => import('./examples/ShareModal')), kind: 'modal', ... },
  comments: { component: lazy(() => import('./examples/CommentSheet')), kind: 'sheet', ... },
  reply: { component: lazy(() => import('./examples/ReplyModal')), kind: 'modal', ... },
};
export const modalRegistry: Record<string, ModalRegistryItem> = {
  share: { component: lazy(() => import('./examples/ShareModal')), kind: 'modal', ... },
  comments: { component: lazy(() => import('./examples/CommentSheet')), kind: 'sheet', ... },
  reply: { component: lazy(() => import('./examples/ReplyModal')), kind: 'modal', ... },
};

Eine Produktionsversion stellt sicher, dass jede Modalliste zu einem eigenen Teil wird, der nur dann geladen wird, wenn diese Eintragung geöffnet wird.

Die URL zur Wahrheit machen

Die Ausrichtung der Adressleiste mit dem Stack ist ein Synchronisierungsproblem zwischen dem Zustandsspeicher und window.location. Wenn man das falsch macht, läuft man endlos im Kreis oder das Zurück-Tasten verlässt die Seite anstelle eines Dialogs zu schließen.

Ein boolescher Referenzwert markiert „Diese Änderung kam aus der URL, schreiben Sie sie nicht zurück“:

export function useModalUrlSync() {
  const setStack = useModalStore((s) => s.setStack);
  const stack = useModalStore((s) => s.stack);
  const syncingFromUrl = useRef(false);

  // stack -> URL
  useEffect(() => {
    if (syncingFromUrl.current) { syncingFromUrl.current = false; return; }
    const serialized = serializeStack(stack);
    const params = new URLSearchParams(window.location.search);
    serialized ? params.set('modals', serialized) : params.delete('modals');
    window.history.pushState({ modals: serialized }, '', `${window.location.pathname}?${params}`);
  }, [stack]);

  // URL -> stack (back/forward button)
  useEffect(() => {
    const onPopState = () => {
      syncingFromUrl.current = true;
      const raw = new URLSearchParams(window.location.search).get('modals') ?? '';
      setStack(deserializeStack(raw));
    };
    window.addEventListener('popstate', onPopState);
    return () => window.removeEventListener('popstate', onPopState);
  }, [setStack]);
}

Ziehen Sie pushState vor replaceState: Jeder geöffnete Dialog fügt echte Historie hinzu, sodass das Zurück-Tasten Schicht für Schicht schließt – genau wie es die Benutzer von drei verschachtelten Dialogen erwarten.

Ausgedrückt anders: Behandeln Sie die Steuerung von Modalen als Infrastruktur und nicht als UI-Zustand, der dem jeweiligen Bildschirm gehört. Die Bildschirme kümmern sich um das Abrufen eigener Daten sowie den lokalen Formzustand. Die Infrastruktur entscheidet, welche Überlagerungen vorhanden sind, in welcher Reihenfolge und wie die Adressleiste diese Struktur widerspiegelt. Genau diese Trennung sorgt dafür, dass ressourcenintensive Komponenten inaktiv bleiben, während Dialoge geöffnet und geschlossen werden.

Falls eine Verschachtelung erforderlich ist, bevorzugen Sie explizite openModal-Aufrufe direkt aus dem Inhalt statt boolescher Flags, die über Elternelemente weitergeleitet werden. Solche Flags führen zu Kopplungen: Jedes Elternelement muss von jedem Kind-Dialog wissen. Ein Registrierungsschlüssel zusammen mit Parametern hält das Elternelement von den Interna des Kindes unabhängig und ermöglicht es dem Manager, die Stapelreihenfolge sowie die Historieeinträge zu steuern.

Wie dies zu bestehenden Bibliotheken passt

Dieses Muster ersetzt das Modal-Ökosystem nicht; es löst lediglich das Problem der Zustandsverwaltung. Überschneidungen mit gängigen Tools:

  • Radix UI Dialog und Vaul zeichnen sich durch gute Barrierefreiheit sowie Gestenfunktionen aus – Fokusbefestigung, Scroll-Lock, Schließen durch Ziehen. Sie legen keinen festen Ort für den „offenen“ Zustand fest, sodass jeweils eines davon als Shell dienen kann. Store, Portal und Stack befinden sich darunter, nicht anstelle davon.
  • NiceModal (@ebay/nice-modal-react) verwendet eine imperative Anzeigemethode (NiceModal.show(MyModal)) ohne boolesche Schalter. Es eignet sich, wenn URL-Synchronisierung und tiefe Verkettung nicht notwendig sind. Das Stack-und-Store-Design entspricht eher der Ergonomie von NiceModal plus einer echten Rückgängigmachungsfunktion.
  • Next.js-intercepting-/parallel-Routen lösen das Problem der URL-Synchronisierung auf Framework-Ebene: Ein Modal ist ein Routensegment innerhalb der Seite. Im App Router ist dies oft üblicher als die manuelle Verwendung von pushState. Der Store-Ansatz folgt derselben Idee für Vite/CRA React oder ältere Pages-Router-Anwendungen ohne eingebaute Intercepting-Routen.
  • Falls eine Bibliothek bereits die Funktionalitäten für Chrome bereitstellt, sollten deren Grundfunktionen als Basis genutzt werden, während Store sowie ModalManager zur Isolation und Verkettung beibehalten werden.

    Ist irgendetwas davon erforderlich? Top-Level-useState in App erfordert weniger Zeilen Code und eignet sich für viele Produkte. Seien Sie konkret bezüglich dessen, was der externe Stack bietet – und warum „weniger Neuladungen“ keine vage Marketingaussage ist.

    Die Kosten für die Abgleichung steigen mit dem Unterbaum und nicht damit, wie gering der Zustandswechsel war. Wenn eine Komponente neu gerendert wird, durchläuft React auch die nicht gememorierten Nachkommen, selbst wenn der DOM-Unterschied minimal ist. Das Umstellen von modalOpen in App bedeutet nicht einfach nur „einen Dialog anzeigen“; es führt alle Funktionen zwischen App und den Endkomponenten erneut aus, berechnet die Abhängigkeiten neu, überprüft wieder useMemo-Funktionen und aktiviert erneut Effekte, deren Abhängigkeiten sich geändert haben. Auf einer einfachen Seite ist das nicht wahrnehmbar. Bei Tabellen, Diagrammen, reichen Editoren oder langen virtualisierten Listen ist es der Unterschied zwischen einer sofortigen Anzeige und einem Verzögerungseffekt von einem oder zwei Frames.

    Die Lage bestimmt den Ausbreitungsbereich, nicht die Größe der Ladung. Ein Boolean-Wert und ein Stack mit fünf Elementen verbrauchen ähnlich viel Speicher; entscheidend ist vielmehr, wer benachrichtigt wird. Wenn der Modal-Zustand in einen externen Speicher verschoben wird, den nur ModalManager liest, reduziert sich die Auswirkung von „ein Modal wurde geöffnet“ von der gesamten Seite auf einen speziellen Komponenten. Andere Komponenten können dies nicht erkennen, es sei denn, sie haben diesen spezifischen Bereich ausgewählt.

    Der Kontext ist nicht die Lösung – auch wenn es so aussieht. Der Kontext beseitigt zwar das Problem des „Prop-Drillings“, stoppt aber keine erneuten Renderungen. Jeder Verbraucher von useContext wird aktualisiert, sobald sich der Wert ändert – selbst wenn er das geänderte Feld ignoriert. Ein Wurzel-Element <ModalProvider> erzeugt wieder den ursprünglichen Ausbreitungsbereich, allerdings über eine andere API. Store-basierte Lösungen wie Zustand, Jotai oder Redux-Selector beheben dieses Problem, indem sie es den Komponenten ermöglichen, einen bestimmten Slice zu überwachen.

    Die Modale öffnen sich zum Schlechtesten möglichen Zeitpunkt, um diese Steuer zu zahlen. Teilschirme, Kommentarstränge und Bestätigungen sollten nach einem Tippen sofort erscheinen. Ein ausfallendes Frame, das bei einem Hintergrundtimer unsichtbar bliebe, fällt auf, wenn es direkt auf einen Klick reagiert.

    Man kann messen anstelle von Behauptungen zu vertrauen. Eine Demo kann einen Render-Zähler auf der Seite sowie in jedem Modal anbringen: Öffnen, Verankern, Schließen – dabei bleibt der Seitenzähler konstant, während jeder Modal einzeln zählt. Der React DevTools Profiler zeigt dasselbe Bild – die Arbeit findet innerhalb des Portals statt, nicht innerhalb des App-Baums der übergeordneten Anwendung.

    Nicht jeder Dialog benötigt diese genaue Struktur. Eine statische Bestätigung ohne Verkettung auf einer einfachen Seite lohnt sich in Bezug auf die Struktur nicht. Es spielt eine Rolle, wenn die Seite unter dem Modal-Element teuer zu neu rendern ist, wenn tatsächlich Verkettungen vorliegen oder wenn der Öffnungs-Zustand nach einem Neuladen über die URL beibehalten werden muss. In solchen Fällen ist das Auslassen der Isolierung keine Theorie – es bedeutet ein vollständiges Neurendern der gesamten Seite bei jedem Öffnen und Schließen für jeden Benutzer.

    Falls ein Team bereits Radix oder Vaul für die Barrierefreiheit standardisiert hat, sollte man zunächst diese Frameworks verwenden und den Store erst einsetzen, wenn Profiler eine aufseitige Verarbeitung beim Öffnen und Schließen zeigen oder das Produkt verknüpfte Abläufe mit vollständiger Funktionalität der Zurück-Taste erfordert. Eine vorzeitige Implementierung solcher Infrastrukturen ist real – genauso wie das vollständige Neurendern des gesamten Systems bei jedem Teilen, sobald das Dashboard stark belastet ist.

    Was Sie daraus gewinnen

    • Das Öffnen oder Schließen eines Modals in jeder Tiefe führt niemals zu einem erneuten Neurendern der Seite – nur die Modal-Schicht liest den Zustand des Modals.
  • Das Nesting ist strukturell: Es handelt sich um Push/Pop-Operationen auf einem Array, keine Sonderfalllösung.
  • Jedes Modal-Body wird geteilt und nach Bedarf geladen.
  • Die URL bleibt ein teilerfähiges, für „Zurück“-Klicks geeignetes Bild dessen, was geöffnet ist – inklusive mehrerer Ebenen.
  • Niemandes der Komponenten ist exotisch: Eine externe Datenbank, ein Portal, lazy Loading sowie die Synchronisierung mit pushState sind alltägliche Werkzeuge. Die interessante Regel ist architektonischer Natur: Was auch immer entscheidet, was geöffnet wird, darf niemals ein Vorgänger dessen sein, das sich nicht darum kümmern sollte.

    Eine vollständige Demo mit Vite + React + TypeScript + Zustand, inklusive drei verschachtelten Modals und einem Echtzeit-Render-Zähler, befindet sich auf GitHub unter react-modal-stack. Nach dem Ausführen von npm install && npm run dev gehen Sie zu „Teilen“ → „Kommentare ansehen“ → „Antworten“, um zu überprüfen, dass die dahinterliegende Seite nicht erneut gerendert wird.