Messung von React-State-Managern: Vergleich der Render-Zahlen und des Bundle-Kosten
Eine Einkaufswagen-App, die mit acht React-State-Mustern entwickelt wurde, bei der die Anzahl der unnötigen Renderungen sowie die Größe des komprimierten Bündels gemessen wurden – und was die Zahlen über Zustand, Valtio und Context aussagen.
Die Frage, welchen React-State-Manager man wählen soll, taucht ständig wieder auf, und die Diskussionen basieren in der Regel eher auf Meinungen als auf Messungen. Ein nützlicherer Ansatz besteht darin, mit jedem gängigen Muster dieselbe kleine Funktion zu entwickeln, das Verhalten durch einen gemeinsamen Testumfang konstant zu halten und herauszufinden, was tatsächlich unterschiedlich ist: wie oft die Komponenten neu gerendert werden und wie viele Kilobyte jede Option hinzufügt. Dieser Artikel zeigt ein solches Experiment mit acht Mustern, erklärt, warum die Zahlen so ausfallen, und gibt Ihnen ein wiederholbares Verfahren, um denselben Vergleich mit Ihrem eigenen State durchzuführen.
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 – React wird dabei ausgeschlossen, da jede Option denselben Aufwand verursacht.
Die acht Konkurrenten sind:
- lifted
useState, der über Props weitergegeben wird - Context in Kombination mit
useReducer - Redux Toolkit
- Zustand
- Jotai
- Valtio
- MobX
- eine selbstgeschriebene Store-Lösung auf Basis von
useSyncExternalStoreohne Abhängigkeiten
Sie alle acht erfüllen den gemeinsamen Vertrag, daher betreffen die nachstehenden Unterschiede nur den Preis, 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 der Laufzeitumgebung sind hier aufgelistet, ebenso wie das Repository, das die acht Implementierungen, den gemeinsamen Test sowie beide Messungsskripte 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 als auch die anderen Lösungen stammen aus Fehlern, die in echten Anwendungen auftreten, und nicht aus Richtlinien für Benchmarking.
Zunächst erstellt jede Implementierung ihren Speicher innerhalb eines Komponenten statt im Modulbereich. Auf diese Weise startet jede geladene Anwendung wirklich frisch, und es kommt zu keinen Zustandslecks zwischen den Testläufen.
Zweitens dient das Theme-Icon ausschließlich als neutrales Element. Es abonniert einen Wert, der durch Klicks niemals geändert wird; daher ist jede Darstellung während des Tests nutzlose Arbeit, die allein dem Zustandsmuster zugeschrieben werden kann.
Was absichtlich außerhalb des Anwendungsbereichs liegt
Der Wettbewerb bezieht sich nur auf den gemeinsamen Client-Zustand. TanStack Query ist nicht enthalten, da es einen Cache mit Serverdaten verwalten würde, was ein anderes Problem mit anderen Regeln darstellt. Der URL-Zustand fehlt, weil die Adressleiste zum Router gehört. Ihre Anwendung benötigt vermutlich beides, doch keines von beiden ist in diesem speziellen Wettbewerb konkurrenzfähig.
Überraschung Nummer eins: Die Codegröße ist nahezu 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 lediglich neunzehn Zeilen länger als der einfachste mögliche Ansatz, lifted state mit 39 Zeilen. In diesem Maßstab reduziert sich der oft zentrale Argument bezüglich des Boilerplates in vielen Diskussionen über State-Management auf neunzehn Zeilen. Die wirklich bedeutenden Unterschiede liegen anderswo und werden erst durch Instrumentierung sichtbar.
Der Render-Scoreboard
Hier ist angegeben, wie oft jede Komponente bei den fünf Klicks – einschließlich des initialen Aufrufs – gerendert wurde:
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
Aus diesen Spalten ergeben sich drei unterschiedliche Erkenntnisse.
Lifted state und Context führen zu einem Neurendern von allem
Durch das gehobene useState und einen einzigen Context wird jede Komponente bei jedem Klick gerendert. Das Theme-Abzeichen wurde sechsmal angezeigt, obwohl sich sein Wert nie änderte, und die Bananen-Button wurde bei jedem Klick auf den Apfel angezeigt. Das ist der konkrete Mechanismus hinter der häufigen Warnung, dass Context kein State-Manager ist. useContext registriert eine Komponente für den gesamten Context-Wert, sodass jede Komponente gerendert wird, sobald sich dieser Wert ändert. Alle State-Daten in einem einzigen Context unterzubringen, entspricht im Grunde einem gehobenen 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 gemessen. Die Darstellung spiegelt Context so wider, wie er am häufigsten implementiert wird: ein Provider mit einem einzigen Wert.
Vier APIs, ein identisches Ergebnis
Redux Toolkit, Zustand, MobX sowie der handgeschriebene Store erzeugen genau denselben Verhalten: Jede Komponente wird beim Initialisieren einmal gerendert und erneut nur dann, wenn sich der Slice, den sie abruft, 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, siehe wie React Redux entscheidet, wann erneut gerendert werden soll.
Valtios verdächtig niedrige Zahlen
Valtios Spalte sieht anfangs 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, entsteht pro Klick eine Neuaufladung, da jeder Klick vor dem Beginn des nächsten abgeschlossen wird. Der Vorteil tritt nur dann auf, 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ührt 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 mitgezählt 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 Muster insgesamt 9,2 KB ausmachen.
Interessant ist, wie diese Liste mit dem Render-Scoreboard zusammenwirkt. Nur zwei Ansätze verbinden ein perfektes Renderverhalten mit einem Speicherverbrauch von unter einem Kilobyte: Zustand und der handgeschriebene Store. Redux Toolkit erreicht denselben Render-Standard bei 10,7 KB und MobX bei 13,2 KB. 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 wert ist.
Drei praktische Optionen und deren Kosten
Für den gemeinsamen Client-State in einer typischen Anwendung deuten die Messungen auf drei Tools hin, die sich lohnen.
Zustand als Standard
Zustand bietet eine perfekte Render-Granularität bei 0,7 KB in 54 Zeilen, und seine API benötigt für neue Teammitglieder fast keine Erklärung: Es handelt sich um einen Hook, der einen Selector entgegennimmt. In diesem Test entsprach er dem Laufzeitverhalten von Redux in etwa einem Fünfzehntel der Größe.
Das Ergebnis hängt von einer einzigen Regel ab: Wählen Sie immer einen spezifischen Selektor aus. Ein Aufruf wie useCart(s => s) registriert das Komponenten-Objekt beim gesamten Store, was das Context-Problem verursacht. Die Effizienz ergibt sich aus der Verwendung präziser Selektoren – nicht aus dem Namen der Bibliothek. Wenn ein Selektor bei jedem Aufruf ein neues Objekt oder Array zurückgibt, benötigen Sie außerdem einen Hilfsfunktion für die flache Gleichheit; sonst wirkt jede Aktualisierung wie eine Änderung.
Ein manuell geschriebener 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’s Render-Struktur und weist keine Abhängigkeiten auf, die überprüft oder aktualisiert werden müssten. useSyncExternalStore ist die von React selbst bereitgestellte Funktion, 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 Nachteil ist, dass Sie selbst für den Code verantwortlich sind: DevTools, Middleware und Persistenz müssen von Ihnen erstellt werden, falls Sie sie benötigen.
Valtio für sporadische Updates
Valtios Microtask-Batching 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 die richtigen Komponenten aktualisiert zu sehen, 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 Zeitreisefunktionen, 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 Anwendung nicht der Fall 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 Zustand zwischen Tests sowie während der Serverdarstellung zwischen Anfragen durchsickern. Wenn Sie aus diesem Vergleich nur eine Regel für Code-Reviews mitnehmen möchten, dann diese: Beschränken 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:
- 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, einer zum Lesen eines abgeleiteten Wertes sowie ein passiver Beobachter-Component.
track()-Aufruf im Körper jeder Komponente benötigt etwa zehn Zeilen Code. Die Anzahl der Neuladungen durch Dritte stellt Ihre „Context-Steuerungskosten“ dar, die gemessen und nicht geschätzt werden.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.Offene Fragen
Zwei Threads sind noch nicht gebunden. Der kleinere ist der Grund für Jotais zusätzliche Renderung. Der größere ist React Compiler. Die Zeile mit 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 Stores im echten Produktionscode in Einklang bringt, anstatt nur in einer Demo, ist das offensichtliche nächste Experiment.
Wichtigste Erkenntnisse
- In kleinem Maßstab sind die Unterschiede in den Standardvorlagen zwischen State-Bibliotheken vernachlässigbar; erst beim Renderverhalten und der Größe des Bundles zeigen sich tatsächliche Unterschiede.
- Ein einziger Context, der sich ändernde State enthält, führt dazu, dass alle Verbraucher neu gerendert werden – einschließlich Komponenten, die den geänderten Wert niemals lesen.
- Auf Selektoren basierende Stores, sei es Redux Toolkit, Zustand, MobX oder ein selbst implementierter
useSyncExternalStore-Store, führen alle zum selben effizienten Rendermuster.