Startseite / Artikel

Zustand vs Redux Toolkit vs Context: React State Manager im Vergleich nach Implementierung und Paketgröße

Eine Einkaufswagen-App, die mit acht React-State-Mustern entwickelt wurde, mit Messungen zu verschwendeten Render-Vorgängen sowie der Größe des komprimierten Bündels: Was die Zahlen über Zustand und Redux Toolkit aussagen.

Verlag: REACTAPP.TOP9 Min. Lesezeit2182 Wörter

Quellen: I Built the Same React App With All 8 State Management Patterns. The Numbers Picked Three Winners.

Welchen React-State-Manager sollten Sie wählen? Diese Frage taucht alle paar Monate wieder auf und hängt meist vom persönlichen Geschmack ab. Eine bessere Antwort erhalten Sie, wenn Sie dieselbe kleine Funktion mit jedem gängigen Muster entwickeln, das Verhalten mit einem gemeinsamen Testsuite festlegen und die beiden tatsächlich unterschiedlichen Faktoren zählen: wie oft die Komponenten neu gerendert werden und wie viele Kilobyte jede Option hinzufügt. Unten finden Sie dieses Experiment mit acht Mustern – von Zustand und Redux Toolkit bis hin zu reinem Context – die Erklärung, warum die Zahlen so ausfallen, sowie ein Verfahren, das Sie auf Ihren eigenen State anwenden können.

Wie das Experiment aufgebaut ist

Die Test-App ist absichtlich sehr klein: ein Warenkorb mit einem Apfel-Button, einem Bananen-Button, einer laufenden Summe sowie einem Themensymbol, dessen Wert sich niemals ändert. Jede Implementierung wird durch denselben Verhaltenstest überprüft, bei dem dreimal auf den Apfel-Button und zweimal auf den Bananen-Button geklickt wird, wobei identische Ergebnisse erwartet werden. Während der Test läuft, protokolliert ein Zähler jede Darstellung jeder Komponente. Separat misst esbuild, was jedes Konzept zu einem komprimierten, gepackten Bundle beiträgt, wobei React ausgeschlossen wird, da jede Option denselben Aufwand verursacht.

Die acht Konkurrenten sind:

  • lifted useState über Props weitergegeben
  • Context in Kombination mit useReducer
  • Redux Toolkit
  • Zustand
  • Jotai
  • Valtio
  • MobX
  • eine selbstgeschriebene Store-Lösung auf Basis von useSyncExternalStore ohne Abhängigkeiten

Sie alle acht erfüllen den gemeinsamen Vertrag, daher betreffen alle Unterschiede nur die Kosten und nicht die Richtigkeit:

✓ lifted useState        › passes the shared cart contract
✓ Context + useReducer   › passes the shared cart contract
✓ Redux Toolkit          › passes the shared cart contract
✓ Zustand                › passes the shared cart contract
✓ Jotai                  › passes the shared cart contract
✓ Valtio                 › passes the shared cart contract
✓ MobX                   › passes the shared cart contract
✓ useSyncExternalStore   › passes the shared cart contract

Tests  8 passed (8)

Die für die Messungen verwendeten Bibliotheksversionen und Laufzeitumgebungen sind hier aufgelistet, zusammen mit dem Repository, das die acht Implementierungen, die gemeinsamen Tests sowie beide Messskripte enthält:

Tested with react@19.2.8, zustand@5.0.15, @reduxjs/toolkit@2.12.0, react-redux@9.3.0,
jotai@2.20.2, valtio@2.3.2, mobx@7.0.3, mobx-react-lite@5.0.3, node 22.
Repo: github.com/noorjsdivs/state-patterns — 8 implementations, 1 shared test, 2 measurements.

Zwei Optionen, um den Vergleich fair zu halten

Sowohl diese Aspekte als auch die damit verbundenen Probleme stammen aus Fehlern in echten Anwendungen und nicht aus Richtlinien für Benchmarking-Verfahren.

Zunächst erstellt jede Implementierung ihren Speicher innerhalb eines Komponenten statt im Modulbereich. Auf diese Weise startet jede geladene Anwendung wirklich von vorne, und es kommt zu keinen Zustandslecks zwischen den Testläufen.

Zweitens dient das Thema-Icon ausschließlich als neutrales Element. Es abonniert einen Wert, der durch Klicks niemals geändert wird; daher ist jede Darstellung, die während des Tests erfolgt, sinnlose Arbeit, für die allein das Zustandsmuster verantwortlich ist.

Was absichtlich außerhalb des Rahmens liegt

Der Wettbewerb bezieht sich ausschließlich auf den gemeinsam genutzten Client-Zustand. TanStack Query fehlt, da es einen Cache mit Serverdaten verwalten muss – das ist ein anderes Problem mit anderen Regeln. Der URL-Zustand fehlt, weil die Adressleiste zum Router gehört. Ihre Anwendung benötigt vermutlich beides, doch keines von beidem konkuriert in diesem speziellen Wettbewerb.

Überraschung Nummer eins: Die Codegröße ist fast identisch

Vor den interessanten Zahlen gibt es eine langweilige, die jedoch Aufmerksamkeit verdient. Die acht Implementierungen umfassen zwischen 39 und 58 Zeilen. Die aufwendigste Variante, Redux Toolkit mit 58 Zeilen, ist nur neunzehn Zeilen länger als der einfachste mögliche Ansatz, lifted state mit 39 Zeilen. In diesem Maßstab entspricht der Argumentation bezüglich des Boilerplates, die in so vielen Diskussionen über Zustandsverwaltung eine zentrale Rolle spielt, lediglich neunzehn Zeilen. Die wirklich bedeutenden Unterschiede liegen anderswo und werden erst durch Instrumentierung sichtbar.

Die Render-Leistungsübersicht

Hier ist angegeben, wie oft jede Komponente bei den fünf Klicks angezeigt wurde, einschließlich des initialen Ladens:

5 clicks (3 apple, 2 banana)     Apple  Banana  Total  Theme(idle)

lifted useState                    6      6       6       6
Context + useReducer               6      6       6       6
Redux Toolkit                      4      3       6       1
Zustand                            4      3       6       1
MobX                               4      3       6       1
useSyncExternalStore               4      3       6       1
Jotai                              5      4       7       2
Valtio                             2      2       2       1

Drei unterschiedliche Szenarien ergeben sich aus diesen Beispielen.

„Lifted State“ und Context rendern alles neu

Durch das „Lifted useState“ sowie einen einzigen Context wird jede Komponente bei jedem Klick neu gerendert. Das Theme-Abzeichen wurde sechsmal angezeigt, obwohl sich sein Wert nie änderte, und die Bananen-Button wurde bei jedem Klick auf die Apfel-Button angezeigt. Das ist der konkrete Mechanismus hinter der häufigen Warnung, dass Context kein State-Manager sei. useContext registriert eine Komponente für den gesamten Context-Wert, sodass jede Komponente neu gerendert wird, sobald sich dieser Wert ändert. Alle State-Daten in einem einzigen Context zu speichern, entspricht im Grunde einem „Lifted State“ mit einer zusätzlichen Schicht.

Die übliche Lösung besteht darin, den State auf mehrere Contexts aufzuteilen oder die Verbraucher-Komponenten zu memoisieren – das wurde hier jedoch nicht getestet. Die dargestellte Variante zeigt Context in der am häufigsten verwendeten Form: ein Provider, der einen einzigen Wert enthält.

Vier APIs, ein identisches Ergebnis

Redux Toolkit, Zustand, MobX sowie der manuell geschriebene Store erzeugen genau dieselbe Ausgabe: Jedes Komponente wird beim Initialisieren einmal gerendert und erneut nur dann, wenn sich der von ihm gelesene Datensatz tatsächlich ändert. Ihre APIs sehen völlig unterschiedlich aus, doch ihr Laufzeitverhalten stimmt überein, weil alle vier auf derselben zugrundeliegenden Idee beruhen. Komponenten hören auf einen ausgewählten Teil des Zustands und nicht auf den gesamten Store, und sie werden nur dann benachrichtigt, wenn sich dieser ausgewählte Teil ändert. Um genauer zu sehen, wie diese Auswahl und Überprüfung der Gleichheit in einer dieser Bibliotheken funktioniert, lesen Sie wie React Redux entscheidet, wann erneut gerendert werden soll.

Valtios verdächtig niedrige Zahlen

Valtios Spalte sieht zunächst wie ein defekter Zähler aus: Zwei Neuaufladungen für einen dreimal geklickten Button. Die Zahlen sind korrekt. Valtio gruppiert Benachrichtigungen über Änderungen in der Microtask-Queue, sodass mehrere schnelle, synchrone Aktualisierungen zu einer einzigen Neuaufladung pro Komponente zusammenfallen, und die endgültigen Werte bleiben weiterhin richtig.

Es gibt jedoch eine wichtige Einschränkung. Wenn jemand in normaler Geschwindigkeit klickt, erfolgt pro Klick eine Neuaufladung, da jeder Klick vor dem Beginn des nächsten abgeschlossen wird. Der Vorteil zeigt sich erst dann, wenn die Aktualisierungen in Wellen eintreffen, wie es bei WebSocket-Nachrichten, strömenden Daten oder Drag-Events der Fall ist. Wenn Ihre Anwendung dieses Verhaltensmusters aufweist, verdient diese Spalte besondere Aufmerksamkeit.

Jotais konstante zusätzliche Neuaufladung

Jotai führte jede Komponente einmal mehr aus als die auf Selektoren basierende Gruppe – einschließlich zweier Ausführungen für das Abzeichen des „Idle“-Themes. Die Granularität ist korrekt, da die Komponenten weiterhin nur auf die von ihnen verwendeten Elemente reagieren, und die zusätzliche Ausführung ist in jeder Spalte gleichmäßig. Dieses Muster deutet auf ein Problem in der ursprünglichen Initialisierungssequenz mit dem Store Provider hin und nicht auf einen Abfluss von Abonnements, doch die genaue Ursache konnte nicht ermittelt werden. Betrachten Sie dies als offene Frage statt als Urteil gegen Jotai.

Die Bewertung der Bundle-Größe

Das zeigt, was jedes Muster zu einem komprimierten Bundle hinzufügt – wobei sowohl der eigene Code des Musters als auch seine Bibliotheken berücksichtigt werden, wobei React als externe Komponente gilt:

lifted useState          0.4 KB
useSyncExternalStore     0.5 KB     (zero dependencies)
Context + useReducer     0.5 KB
Zustand                  0.7 KB
Valtio                   2.7 KB
Jotai                    4.4 KB
Redux Toolkit           10.7 KB
MobX                    13.2 KB

Die größte Option ist 33 Mal größer als die kleinste. Mit anderen Worten: Redux Toolkit und MobX zusammen wiegen 23,9 KB, während die restlichen sechs Ansätze insgesamt 9,2 KB ausmachen.

Interessant ist, wie diese Liste mit der Leistungsübersicht zusammenhängt. Nur zwei Ansätze verbinden ein perfektes Renderverhalten mit einer Größe von unter einem Kilobyte: Zustand und der handgeschriebene Store. Redux Toolkit erreicht bei 10,7 KB und MobX bei 13,2 KB denselben Leistungsstand. Das ist nicht automatisch ein Nachteil für sie – es handelt sich dabei um einen Preis, und ein Preis macht nur dann Sinn, wenn man weiß, was er einbringt.

Drei praktische Auswahlmöglichkeiten und deren Kosten

Für den gemeinsamen Client-State in einer typischen Anwendung deuten die Messwerte auf drei Tools hin, die es wert sind, in Betracht gezogen zu werden.

Zustand als Standard

Zustand bietet eine perfekte Render-Granularität bei 0,7 KB in 54 Zeilen, und seine API benötigt für einen neuen Teamkollegen fast keine Erklärung: Es handelt sich um einen Hook, der einen Selektor entgegennimmt. In diesem Test entsprach er dem Laufzeitverhalten von Redux bei etwa einem Fünfzehntel der Größe.

Dieses Ergebnis hängt von einer Regel ab: Man sollte immer einen Slice auswählen. Ein Aufruf wie useCart(s => s) registriert das Komponenten bei dem gesamten Store, was das Context-Problem erzeugt. Die Effizienz ergibt sich aus der Verwendung schmaler Selektoren – nicht aus dem Namen der Bibliothek. Wenn ein Selektor bei jedem Aufruf ein neues Objekt oder Array zurückgibt, benötigt man außerdem einen Hilfsfunktion für flache Gleichheit, da sonst jede Aktualisierung wie eine Änderung erscheint.

Ein handgeschriebener useSyncExternalStore-Store

Diese Option macht das Experiment am überzeugendsten. Die vollständige Implementierung, einschließlich des Stores, passt in 58 Zeilen, fügt 0,5 KB hinzu, entspricht exakt Redux’ Render-Spalte und weist keine Abhängigkeiten auf, die überprüft oder aktualisiert werden müssten. useSyncExternalStore ist die primitive Funktion, die React selbst bereitstellt, um sich sicher an externe Stores anzubinden – auch bei gleichzeitiger Renderung. Für Autoren von Bibliotheken oder Teams, die minimale Abhängigkeiten bevorzugen, reicht dies aus. Der Kompromiss besteht darin, dass Sie selbst für den Code verantwortlich sind: DevTools, Middleware und Persistenz müssen Sie selbst erstellen, falls Sie sie benötigen.

Valtio für sporadische Updates

Valtios Microtask-Gruppierung war messbar, real und einzigartig unter den Konkurrenten, und 2,7 KB sind ein angemessener Preis dafür. Die direkte Änderung wie state.apples++ zu schreiben und genau zu sehen, wie die richtigen Komponenten aktualisiert werden, erscheint fast zu bequem – doch die Render-Zahlen bestätigen, dass das Verhalten echt ist.

Warum die anderen diesen Wettbewerb verlieren, obwohl sie nicht schlecht sind

Die Form des Tests spielt eine Rolle und begünstigt einen kleinen, flachen Zustand. Die anderen Optionen haben Stärken, die diese Lösung nicht nutzen kann:

  • Redux Toolkit mit 10,7 KB kompensiert die Kosten durch Entwicklertools, Debugging mit Zeitreisefunktion, Middleware sowie Konventionen, die in großen Organisationen funktionieren. Mit fünfzig Entwicklern an einem Codebase sind gerade diese Konventionen das eigentliche Produkt.
  • MobXs beobachtbares Modell ist in domänenspezifischem Code mit vielen Klassen am stärksten – was bei dieser App nicht der Fall ist.
  • Jotai’s Atom-Graph leuchtet auf, wenn der abgeleitete Zustand tief und vernetzt wird, wobei ein einzelner abgeleiteter Gesamtwert diesen kaum erreicht.
  • Der Kontext bleibt das richtige Werkzeug für Werte, die selten ändern, wie z. B. Theme, Lokalisation oder Session. Ironischerweise war die Spalte für das Theme genau dort am schlechtesten, weil der Warenkorbzustand denselben Anbieter teilte.
  • Lifted State ist weiterhin geeignet für Zustände, die nicht geteilt werden. Er verlor nur, weil dieser Wettbewerb speziell dem Teilen gewidmet ist.
  • Eine Gestaltungsregel, die kein Geschmackssache ist

    Jede hier gezeigte Implementierung hat ihren Store innerhalb eines Components erstellt. Ein auf Modulebene definierter Store überdauert das Entfernen des Components, wodurch ein Warenkorb nach einem Ausloggen weiterhin vorhanden bleibt und den nächsten Benutzer, der sich am selben Gerät anmeldet, begrüßt. Dadurch kann außerdem der Zustand zwischen Tests sowie zwischen Anfragen während der Serverdarstellung „durchsickern“. Wenn Sie aus diesem Vergleich nur eine Regel für Code-Reviews mitnehmen möchten, dann diese: Begrenzen Sie Stores auf den Component-Tree, in der Regel indem Sie sie in einem Provider erstellen – es sei denn, Sie möchten absichtlich eine globale Lebensdauer.

    Dieselbe Konkurrenz in Ihrer eigenen App ausführen

    Sie können diese Messung für Ihren eigenen Zustand bereits am Nachmittag durchführen:

    1. Wählen Sie den am meisten diskutierten gemeinsamen Zustand in Ihrer App aus und erstellen Sie um ihn herum ein System aus vier Components: zwei Components zur Schreibweise, ein Component zur Leseweise eines abgeleiteten Wertes sowie ein passiver Beobachter-Component.
    2. Fügen Sie einem each Component einen Render-Zähler hinzu. Ein Objekt auf Modulebene zusammen mit einem track()-Aufruf im Körper jedes Components benötigt etwa zehn Zeilen Code. Die Anzahl der Neuladungen durch Dritte stellt Ihre „Context-Steuerungskosten“ dar, die gemessen und nicht geschätzt werden.
    3. Messen Sie jeden Kandidaten mit esbuild --bundle --minify, wobei React als externe Bibliothek markiert werden muss. Dies dauert nur Sekunden und fügt eine Spalte mit den Kilobytenanzahlen zur Diskussion hinzu.
    4. Falls Sie Context verwenden und Dritte die Komponenten erneut rendern, teilen Sie den Context entweder auf oder wechseln Sie zu einem auf Selektoren basierenden Store, führen Sie anschließend den Zähler erneut aus und fügen Sie die Werte vor und nach der Änderung in Ihren Pull Request ein.
    5. Wiederholen Sie das gesamte Verfahren, sobald React Compiler Teil Ihres Build-Prozesses ist, da die automatische Memoisierung die Zeilen mit dem „gelifteten State“ erheblich verändern kann.

    Offene Fragen

    Zwei Threads sind noch ungebunden. Der kleinere ist die Ursache für Jotais zusätzliche Renderung. Der größere ist React Compiler. Die Zeile „lifted-state“ zeigt 6/6/6/6, genau weil nichts darin gememorisert wird – und Memorisierung ist genau das, was der Compiler automatisiert. Ob dies die Zeile mit den auf Selektoren basierenden Speichern in echtem Produktionscode in Einklang bringt, anstatt nur in einer Demo, ist das offensichtliche nächste Experiment.

    Was die Render-Zahlen und die Bundle-Größen bedeuten

    • In kleinem Maßstab sind die Unterschiede in den Standardkonstrukten zwischen State-Bibliotheken vernachlässigbar; erst beim Renderverhalten und der Bundle-Größe zeigen sich tatsächliche Unterschiede.
    • Ein einziger Context, der sich ändernde Zustände enthält, führt dazu, dass alle Verbraucher neu gerendert werden – einschließlich Komponenten, die den geänderten Wert niemals lesen.
    • Stores, die auf Selektoren basieren – sei es Redux Toolkit, Zustand, MobX oder ein selbst implementierter useSyncExternalStore-Store – folgen alle demselben effizienten Render-Muster.
    • Zustand sowie ein selbst geschriebener Store liefern dieses Verhalten bei einer Größe von unter einem Kilobyte; größere Bibliotheken verdanken ihre Größe Werkzeugen und Konventionen, nicht dem Rendering.
    • Valtios Batching-Verfahren ist vor allem bei unregelmäßigen Update-Strömen vorteilhaft, nicht bei normalen Klickvorgängen.
    • Erstellen Sie Stores innerhalb des Komponentenbaums, damit der Zustand nicht länger existiert als die Sitzung, die ihn erzeugt hat.