Startseite / Artikel / Warum es Regeln für Hooks gibt: Fiber, Hook-Listen und Dispatcher

Warum es Regeln für Hooks gibt: Fiber, Hook-Listen und Dispatcher

Ein Überblick über die Interna von React: Fiber-Node, doppeltes Puffern, „Lanes“, die Hook-Linkliste sowie die Art und Weise, wie jede eingebaute Hook-Familie ihren Zustand speichert und Aufgaben planen lässt.

2852 Wörter

Die meisten React-Entwickler können die Regeln der Hooks auswendig, doch nur wenige können erklären, warum deren Verletzung den Zustand verfälscht anstatt einfach einen hilfreichen Fehler auszulösen. Die Antwort liegt in Reacts Interna: Jeder Hook-Aufruf wird zu einem Knoten in einer auf einem Fiber gespeicherten Verknüpfungsliste, und React findet jeden Knoten ausschließlich nach der Reihenfolge, in der die Hooks aufgerufen werden. Diese Anleitung führt durch Fiber, das Hook-Objekt, die Dispatcher, zwischen denen React bei Renderings wechselt, sowie die internen Mechanismen jeder Hook-Familie, sodass die Regeln nicht mehr willkürlich erscheinen und eher wie Konsequenzen des Designs wirken.

Um eine Form zu erstellen oder Daten abzurufen, benötigen Sie dieses Wissen nicht. Es lohnt sich jedoch, wenn Sie einen veralteten Wert debuggen, zwischen useEffect und useLayoutEffect entscheiden müssen oder sich fragen, warum ein gememorisierter Kindkomponente dennoch neu gerendert wird.

Kurzer Überblick: Was die Hooks ersetzt haben und welche Regeln damit einhergehen

Hooks kamen mit React 16.8 hinzu, und die eingebauten Hooks haben sich auf etwa siebzehn erhöht. Sie lösten drei seit langem bestehende Probleme bei Klassenkomponenten: Zustandsbasierte Logik war schwer in verschiedenen Komponenten wiederverwendbar, verwandte Logiken waren über Lebenszyklusmethoden verstreut, was dazu führte, dass die Komponenten übermäßig aufgebläht wurden, und die JavaScript-Klassen selbst (das Binden von this, das Verständnis der Lebenszyklen) verwirrten viele Entwickler.

Mit den Hooks kamen auch eine Reihe von Regeln:

  1. Rufen Sie Hooks auf der obersten Ebene des Körpers einer Funktionskomponente auf.
  2. Rufen Sie Hooks auf der obersten Ebene des Körpers eines benutzerdefinierten Hooks auf.
  3. Rufen Sie niemals Hooks innerhalb von Bedingungen oder Schleifen auf.
  4. Rufen Sie niemals Hooks nach einem vorzeitigen return in einer Bedingung auf.
  5. Rufen Sie niemals Hooks innerhalb von Ereignishandlern auf.
  6. Rufen Sie niemals Hooks in Klassenkomponenten auf.
  • Rufen Sie niemals Hooks innerhalb der Callbacks auf, die Sie an useEffect, useMemo oder useReducer übergeben.
  • Rufen Sie niemals Hooks innerhalb von try, catch oder finally-Blöcken auf.
  • Verstöße führen zu Warnungen, Fehlern oder, noch schlimmer, zu subtilen Fehlern. Die kurze Erklärung ist, dass die Hooks einer Komponente eine einzellig verknüpfte Liste bilden, die an einen Fiber-Node angehängt ist – ein Zustandsobjekt in JavaScript, das React für jede Komponente bereitstellt. Um zu verstehen, warum das wichtig ist, beginnen Sie mit Fiber selbst. Wenn Sie es bereits gut kennen, springen Sie direkt zum Abschnitt über das Hook-Objekt. Für eine sanftere Einführung in Reconciliation und Zustand sehen Sie eine mentale Modellierung für React-Reconciliation, Zustand und Hooks.

    React Fiber: Der Motor, in dem die Hooks existieren

    Fiber ist Reacts Reconciliation-Engine, die in React 16 als komplette Neugestaltung der Art eingeführt wurde, wie React UI-Änderungen berechnet und anwendet.

    Das Problem mit dem alten Stack-Reconciler

    Vor Fiber verwendete React das sogenannte Stack-Reconciler. Bei jeder Aktualisierung durchlief es den Komponentenbaum rekursiv, und ein rekursiver Ablauf auf dem JavaScript-Callstack kann nicht mitten im Lauf pausiert werden: Sobald er beginnt, läuft er weiter, bis der gesamte Baum verarbeitet ist. Bei einem großen Baum, der den einzigen Hauptthread blockierte, froren daher Animationen ein, Tasteneingaben verzögerten sich und die Benutzeroberfläche ruckelte. Noch schlimmer war, dass es keine Möglichkeit gab, eine dringende Aktualisierung, wie beispielsweise einen Tastendruck, vor einer großen, weniger wichtigen Render-Aktion, die bereits im Gange war, durchzusetzen.

    Was Fiber möglich macht

    Eine Fiber ist ein einfaches JavaScript-Objekt, das eine Arbeitseinheit darstellt und mit einer Komponenteninstanz oder einem DOM-Node verknüpft ist. Da React diese Einheiten selbst verfolgt anstatt sich auf den Aufrufstapel zu verlassen, erhält es drei Vorteile:

    • Pause und Fortsetzen. React kann mitten in der Ausführung anhalten, dem Browser die Bearbeitung dringenderer Aufgaben wie Benutzereingaben überlassen und später an derselben Stelle fortfahren.
    • Priorisierung. Dringende Aktualisierungen können weniger wichtige überschreiben.
    • Wiederverwenden oder wegwerfen. Wenn der Benutzer während einer laufenden Renderung abwandert, kann die unvollendete Arbeit einfach weggeworfen werden.

    Die Struktur einer Fiber-Node

    Jedes React-Element – egal ob Komponente, Host-DOM-Element oder Textknoten – erhält eine entsprechende Fiber. Es handelt sich dabei um ein großes Objekt, das die Props der Komponente, ihren Zustand sowie einen Verweis auf ihre DOM-Repräsentation enthält.

    Anstatt Kinder in Arrays zu speichern, bilden Fibers durch drei Zeiger einen Baum:

    • child führt zum ersten Kind der Fiber.
    • sibling führt zur nächsten Fiber auf derselben Ebene.
    • return führt zurück zum Elternteil.

    Die Verarbeitung einer Aktualisierung bedeutet, diesen Verknüpfungen zu folgen: so tief wie möglich über child nach unten gehen, über sibling seitwärts wechseln und bei Abschluss eines Zweigs über return wieder nach oben klettern. Da es sich dabei um einen gewöhnlichen Schleifenprozess über Zeiger und nicht um Rekursion handelt, kann React zwischen beliebigen beiden Fibers anhalten.

    Doppeltes Puffern mit zwei Bäumen

    Fibers leihen sich das Konzept des doppelten Pufferns aus der Grafikprogrammierung. Zu jedem Zeitpunkt hält React zwei Fiber-Bäume im Speicher:

    • Der aktuelle Baum spiegelt genau das wider, was auf dem Bildschirm angezeigt wird. React ändert ihn während der Berechnung einer Aktualisierung nicht.
    • Der in Bearbeitung befindliche (WIP) Baum wird im Hintergrund erstellt, wenn sich etwas ändert. React kloniert die aktuellen Fibers, die aktualisiert werden müssen, und stellt die neue Version neben die alte.

    Sobald der WIP-Baum fertig ist, wechselt React den Root-Pointer. Der WIP-Baum wird zum aktuellen Baum, und der Bildschirm zeigt den neuen Zustand an. Jede Fiber besitzt einen alternate-Pointer auf ihre Entsprechung im anderen Baum – so wird der Zustand von Hooks von einer Render-Runde zur nächsten übertragen.

    Render-Phase und Commit-Phase

    Diese Architektur teilt jede Aktualisierung in zwei Phasen auf.

    Die Render-Phase ist unterbrechbar. React durchläuft den Baum, ruft die Funktionen Ihrer Komponente auf, führt Hooks aus und vergleicht das Ergebnis mit dem aktuellen Baum, während gleichzeitig ein WIP-Baum im Speicher erstellt wird. Da React den Durchlaufzyklus kontrolliert, kann sein Scheduler alle paar Millisekunden an den Browser übergeben werden. Wenn der Benutzer während einer Render-Aufgabe niedriger Priorität eingibt, kann React pausieren, die Eingabe verarbeiten und anschließend fortfahren. Zudem kann er den gesamten WIP-Baum ignorieren, wenn eine neuere, dringendere Aktualisierung diesen überholt. Da ein Render mehrfach ausgeführt werden kann oder gar nicht abgeschlossen wird, müssen die Funktionen der Komponente rein sein: Es dürfen während des Renderings keine Nebeneffekte auftreten.

    Die Commit-Phase ist synchron.Sobald der WIP-Baum fertiggestellt ist, wendet React die berechneten Änderungen auf den tatsächlichen DOM auf einmal an. Dieser Schritt kann nicht pausiert werden, denn ein Abbruch mitten in DOM-Änderungen würde dem Benutzer eine teilweise aktualisierte, inkonsistente Benutzeroberfläche zeigen. Hier laufen Layout-Effekte ab, passive Effekte werden von hier aus geplant und Refs werden angehängt.

    Lanes: Wie React entscheidet, was unterbrochen werden soll

    Um zu wissen, was was unterbrechen kann, kennzeichnet React jede Aktualisierung mit einem Lane. Lanes werden als Bits in einer Bitmask darstellt, was das Kombinieren und Vergleichen von Prioritäten kostengünstig macht. Im Großen und Ganzen:

    • Ein Sync-Lane für diskrete, dringende Interaktionen wie Klicks und Tastendrücke (kontinuierliche Ereignisse wie Hovering oder Scrollen erhalten ihren eigenen hochpriorisierten Lane).
    • Transition-Lanes für unterbrechbare Aufgaben: Hintergrundaktualisierungen, datengetriebene Neuladungen, Umschalten von Tabellen.
  • Wiederholungswege für Suspense-Grenzen, die gerade verarbeitet werden.
  • Man berührt Fiber niemals direkt, doch es bildet die Grundlage der wichtigsten Funktionen von React 18 und 19. Konkurrenzrendering, Suspense, useTransition und useDeferredValue sind alle davon abhängig, dass das Rendering unterbrochen werden kann.

    Das Hook-Objekt und warum die Aufrufreihenfolge entscheidend ist

    Funktionskomponenten verfügen über keine Instanz zum Speichern von Zustand, daher speichert React diesen im Fiber in einem Feld namens memoizedState. Bei Funktionskomponenten verweist dieses Feld auf das erste Hook in einer eindimensionalen Verknüpfungsliste.

    Jeder Hook-Aufruf während des Renderings entspricht einem Objekt, das in etwa so aussieht:

    {
      memoizedState: any,        // The internal state of the hook
      baseState: any,            // The state before any unprocessed updates
      baseQueue: Update | null,  // Updates that were skipped due to priority
      queue: UpdateQueue | null, // Circular linked list of pending state updates
      next: Hook | null          // Pointer to the next hook in the component
    }
    

    Der memoizedState des Hooks speichert seinen Wert (den Zustand für useState, das Effekt-Record für useEffect, das gecachte Paar für useMemo), queue enthält ausstehende Aktualisierungen, baseState und baseQueue verfolgen Aktualisierungen, die übersprungen wurden, weil ihr Pfad nicht verarbeitet wurde, und next verweist auf den nächsten Hook.

    Achten Sie darauf, was fehlt: Es gibt weder einen Schlüssel noch einen Namen. Beim Neuberechnen durchläuft React einfach die Liste von vorne und verbindet den ersten Hook-Aufruf mit dem ersten Knoten, den zweiten mit dem zweiten und so weiter. Wenn ein Hook innerhalb eines if-Blocks aufgerufen wird und sich die Bedingung ändert, wird jeder nachfolgende Aufruf mit dem falschen Knoten verknüpft, wodurch der Zustand eines Hooks in einen anderen übergeht. Das ist der grundlegende Grund für die Regeln der Hooks: Sie garantieren, dass dieselben Hooks in derselben Reihenfolge bei jeder Neuberechnung ausgeführt werden. Die Regeln zu Schleifen, vorzeitigen Rückgängen, try-Blöcken und Callbacks sind alle Variationen derselben Anforderung.

    Eine moderne Ausnahme bestätigt dieses Prinzip: Die use-API in React 19 kann bedingungsweise aufgerufen werden, genau weil sie nicht auf dieselbe Weise von einem Slot in dieser Liste abhängt.

    Dispatchers: derselbe Hook-Name, unterschiedliche Implementierungen

    Der useState, den Sie importieren, ist nur eine schlichte Umhüllung. Bei der Ausführung leitet er an den jeweiligen Dispatcher weiter, den React für die aktuelle Phase installiert hat. Ältere React-Versionen stellen diesen über ReactCurrentDispatcher zur Verfügung; in neueren Versionen befindet sich der Dispatcher im internen gemeinsamen Objekt von React, doch das Prinzip bleibt dasselbe.

    • HooksDispatcherOnMount ist beim ersten Render aktiv. useState wird auf mountState abgebildet, welches ein neues Hook-Objekt zuweist, seinen Anfangszustand setzt und es am Ende der Liste einfügt.
    • HooksDispatcherOnUpdate ist bei erneuten Renders aktiv. useState wird auf updateState abgebildet, welches sich entlang der vorhandenen Liste vorantreibt (effektiv workInProgressHook = workInProgressHook.next), die ausstehende Warteschlange verarbeitet und den neuen Zustand zurückgibt.
  • ContextOnlyDispatcher wird installiert, sobald React keinen Komponenten darstellt. Jeder über ihn aufgerufene Hook führt zu einem Fehler, wodurch der „ungültige Hook-Aufruf“-Fehler entsteht, wenn man einen Hook außerhalb einer Komponente aufruft.
  • Dieses Design erklärt auch, warum der Aufruf von Hooks in Event-Handler fehlschlägt: Zum Zeitpunkt des Ausführungs des Handlers ist das Rendering bereits abgeschlossen und der fehlerhafte Dispatcher ist bereits aktiv.

    Wie jede Hook-Familie intern funktioniert

    Sämtliche Hooks teilen die Grundlage einer Verknüpfungsliste, unterscheiden sich jedoch stark darin, was sie speichern und zu welchem Zeitpunkt ihre Aufgaben ausgeführt werden.

    Zustands-Hooks: useState und useReducer

    Intern ist useState im Grunde useReducer mit einem eingebauten Reducer, der entweder den neuen Wert zurückgibt oder Ihre Aktualisierungsfunktion mit dem vorherigen Wert aufruft. Beide teilen sich ein und dasselbes Ausführungsmodell:

    • Speicherung. Der Hook bewahrt einen Basenzustand (den zuletzt gespeicherten Wert) sowie eine Aktualisierungsqueue auf, die eine zyklische verknüpfte Liste der ausstehenden Änderungen darstellt.
    • Ausführung. Durch Aufruf eines Setters, beispielsweise setCount(c => c + 1), wird ein Aktualisierungsobjekt erstellt, das diese Aktion enthält; dieses wird der Queue hinzugefügt und die zugehörige Fiber als arbeitsbedürftig markiert, indem ein Lane zugewiesen wird (ältere Versionen verwendeten zu diesem Zweck Ablaufzeiten).
    • Lösung. Während der nächsten Darstellung durchläuft React die Queue und führt jede Aktion nacheinander aus, um den neuen memoizedState zu erzeugen. Aktualisierungen, deren Lane nicht in der aktuellen Darstellung berücksichtigt wird, bleiben in baseQueue und werden später erneut ausgeführt, wodurch die Reihenfolge unabhängig von Prioritäten gewahrt bleibt.

    Deshalb sind Update-Funktionen auch die sichere Wahl, wenn der nächste Zustand vom vorherigen abhängt: Sie werden nacheinander auf den bislang von der Warteschlange berechneten Zustand angewendet.

    Effect-Hooks: useInsertionEffect, useLayoutEffect und useEffect

    Jeder Effect-Hook speichert ein Effekt-Datensatz in seinem Hook-Zustand, der die Setup-Funktion, die Cleanup-Funktion sowie den Abhängigkeitsarray enthält. Die Datensätze werden außerdem in einer separaten Liste im updateQueue der Fiber aufgereiht, und Effekte, deren Abhängigkeiten sich geändert haben, werden markiert, damit die Commit-Phase weiß, welche ausgeführt werden sollen. Die drei Hooks unterscheiden sich in ihrer Ausführungszeit:

    • useInsertionEffect wird vor den Layout-Effekten ausgeführt, also vor jedem Code, der das Layout lesen könnte. Er existiert für CSS-in-JS-Bibliotheken, die frühzeitig <style>-Regeln einfügen müssen, damit die Styles bereits vorhanden sind, wenn das Layout gemessen wird, und so eine wiederholte Neuberechnung der Styles vermieden wird.
    • useLayoutEffect wird synchron ausgeführt, sobald React den DOM geändert hat, aber noch bevor der Browser die Möglichkeit hat, etwas darzustellen. Der Hauptthread wird blockiert, bis der Effekt sowie seine Aufräumarbeiten abgeschlossen sind; daher eignet er sich zum Messen und Anpassen von DOM-Node vor dem Zeitpunkt, an dem der Benutzer etwas sieht, und nicht für aufwändigere Aufgaben.
  • useEffect ist passiv. Es wird in der Regel nachdem der Browser gezeichnet hat ausgeführt, wobei dies über Reacts Scheduler geplant wird (der MessageChannel verwendet und bei Bedarf auf setTimeout ausweicht), sodass es die visuelle Aktualisierung nicht verzögert. Beachten Sie, dass React bei einer Aktualisierung durch eine explizite Benutzereingabe passive Effekte möglicherweise vor dem Zeichnen ausführen kann.
  • Leistungs-Hooks: useMemo und useCallback

    Diese sind Caches, die teure Neuberechnungen vermeiden oder Referenzen zwischen Render-Vorgängen stabil halten.

    • Speicherung. Der Hook speichert ein Paar: den gecachten Wert sowie den Abhängigkeitsarray, mit dem er berechnet wurde.
    • Ausführung. Bei einer erneuten Darstellung vergleicht React jede neue Abhängigkeit mit der im Cache gespeicherten mithilfe von Object.is. Wenn alle übereinstimmen, wird die Factory nicht aufgerufen und der im Cache gespeicherte Wert zurückgegeben. Wenn sich einige unterscheiden, ruft React die Factory auf, speichert den neuen Wert sowie die Abhängigkeiten und gibt das Ergebnis zurück.
    • useCallback ist äquivalent zu useMemo(() => fn, deps): Es speichert das von Ihnen übergebene Funktionsobjekt anstelle eines durch dessen Aufruf erzeugten Wertes. Reacts Quellcode implementiert es getrennt, doch das Verhalten ist dasselbe.

    Weil der Vergleich oberflächlich ist, macht eine Abhängigkeit, die bei jeder Darstellung ein neu erstelltes Objekt oder Array ist, den Cache völlig nutzlos.

    Veränderliche Wert-Hooks: useRef und useImperativeHandle

    Refs speichern Informationen, die nicht für die Darstellung verwendet werden, wie beispielsweise ein DOM-Node oder eine Timeout-ID.

    • useRef ist der einfachste Hook im Codebase. Beim Mount wird { current: initialValue } erstellt und als Zustand des Hooks gespeichert; jede nachfolgende Renderung gibt genau dasselbe Objekt zurück. Das Schreiben in current berührt niemals die Update-Queue oder -Lanes, wodurch auch keine Renderung ausgelöst wird.
    • useImperativeHandle passt an, was ein Elternteil über einen Ref sieht, indem es eigene Methoden daran hängt. Intern verhält es sich wie useLayoutEffect: Es wird synchron während des Commit ausgeführt, sodass der Handle bereits zur Zeit des Ausführens der Effekte des Elternteils verfügbar ist. In React 19 können Funktionskomponenten ref als normales Prop erhalten, weshalb forwardRef nicht mehr erforderlich ist, um ihn zu verwenden.

    Der Context-Hook: useContext

    useContext fällt auf, weil es niemals einen Platz in der Liste der Hooks einnimmt.

    • Lesen. Es liest den Wert vom nächstgelegenen passenden Provider über der Komponente und speichert diesen Kontext in der Abhängigkeitsliste der Fiber.
    • Verbreiten. Wenn sich der Wert eines Providers ändert, sucht React darunter nach Fibers, deren Abhängigkeiten diesen Kontext enthalten, und plant deren Neuberechnung. Dies geschieht auch dann, wenn eine Zwischenkomponente mithilfe von React.memo oder shouldComponentUpdate abbricht – deshalb schützt das Memoisieren eines Elternteils die Kontextnutzer nicht.

    Konkurrierende Hooks: useTransition und useDeferredValue

    Dieses Paar gibt Ihnen Zugriff auf das Lane-System und ermöglicht es, lange Render-Vorgänge zu unterbrechen.

    • useTransition gibt [isPending, startTransition] zurück. Innerhalb von startTransition(() => setQuery(text)) vorgenommene Aktualisierungen erhalten einen Übergangsweg anstelle eines dringenden Weges. Wenn während der Darstellung des Übergangs ein Klick oder Tastendruck eingeht, gibt React den WIP-Baum auf, kümmert sich um die dringende Aktualisierung und startet den Übergang anschließend von vorne.
    • useDeferredValue umhüllt einen Wert statt eines Setters. React behält effektiv zwei Versionen bei: Zunächst wird mit dem vorherigen Wert dargestellt, damit das Bildschirmverhalten weiterhin reaktiv bleibt, anschließend wird eine mit geringerer Priorität ausgeführte Hintergrunddarstellung mit dem neuen Wert geplant.

    Spezial-Hooks: useId und useSyncExternalStore

    Einige Hooks kommen selten im Anwendungscode vor, sind aber für Bibliotheksentwickler unerlässlich.

    • useId verhindert Inkonsistenzen bei der Hydratierung beim Server-Seitigen Rendering. Es erzeugt eine ID aus der Position des Komponentenbaums. Da die Struktur des Baumes auf Server und Client während der initialen Hydratierung identisch ist, stimmen die IDs ohne einen globalen Zähler überein.
    • useSyncExternalStore ersetzt manuell geschriebene useEffect-Abonnements an externen Speichern wie Redux oder Zustand. Man gibt ihm zwei Funktionen: eine, die einen Änderungslistener am Speicher registriert, und getSnapshot, das den aktuellen Wert des Speichers zurückgibt. React liest den Snapshot während des Renderings und ggf. erneut synchron, falls sich der Speicher währenddessen ändert, sodass kein Teil der Benutzeroberfläche eine andere Version des Speichers anzeigt als ein anderer Teil. Dadurch wird das „Tearing“ verhindert, das durch gleichzeitiges Rendering sonst entstehen könnte.

    Hauptpunkte

    • Der Zustand eines Hooks ist eine verknüpfte Liste innerhalb der Fiber, die allein durch die Aufrufreihenfolge bestimmt wird; jede Hook-Regel dient dazu, diese Reihenfolge bei jeder Darstellung unverändert zu halten.
    • Durch die Fiber wird die Darstellung in einen unterbrechbaren Loop umgewandelt, und das Doppelbuffering ermöglicht es React, einen neuen Baum vorzubereiten, ohne das auf dem Bildschirm Anzeigete zu verändern.
    • Die Render-Phase kann mehrfach ausgeführt werden und muss rein bleiben; die Commit-Phase wird einmal, synchron, ausgeführt und ist der Zeitpunkt, an dem Effekte ausgelöst werden.
    • Dispatchers erklären sowohl die Trennung zwischen Mount und Update als auch den Fehler, der auftritt, wenn ein Hook außerhalb der Render-Phase aufgerufen wird.
    • Durch das Wissen darüber, wo jeder Hook seine Daten speichert und zu welchem Zeitpunkt seine Aufgaben ausgeführt werden, fällt es leichter, den richtigen Zeitpunkt für Effekte zu wählen, die Memoisierung effektiv zu nutzen sowie über Kontext und gleichzeitige Aktualisierungen nachzudenken.

    Zusätzliche Literatur

  • Ein minimaler useFetch-Hook: Wann man auf React Query verzichten kann – und seine Einschränkungen — Erstellen Sie einen kleinen, gekachelten useFetch-Hook mit AbortController, TTL-Ablaufzeit sowie einem begleitenden useMutation-Hook und erfahren Sie genau, welche Funktionen von React Query Sie aufgeben müssen.