Accueil / Articles

Zustand vs Redux Toolkit vs Context : Gestionnaires d’état React selon Renders et taille du bundle

Une application de panier construite à l’aide de huit schémas d’état React, évaluée en fonction des rendus inutiles et de la taille du bundle compressé : ce que les chiffres révèlent sur Zustand et Redux Toolkit.

Éditeur : REACTAPP.TOP9 min de lecture2182 mots

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

Quel gestionnaire d’état React choisir ? Cette question revient régulièrement, et la réponse dépend principalement des préférences personnelles. Vous obtiendrez une réponse plus précise en développant la même petite fonctionnalité avec chaque pattern courant, en enregistrant le comportement à l’aide d’un ensemble de tests commun, et en comptant les deux éléments qui diffèrent réellement : la fréquence à laquelle les composants se re-rendernent et le nombre de kilooctets ajoutés par chaque option. Ci-dessous figure cet experiment avec huit patterns, allant de Zustand et Redux Toolkit à Context pur, les raisons pour lesquelles les chiffres sont ce qu’ils sont, ainsi qu’une méthode que vous pouvez réutiliser pour analyser votre propre état.

Comment l’experiment est organisé

L’application de test est délibérément très simple : un panier d’achat avec un bouton pour la pomme, un bouton pour la banane, un total en temps réel et un insigne de thème dont la valeur ne change jamais. Chaque implémentation est testée avec le même test comportemental, qui clique sur la pomme trois fois et sur la banane deux fois, puis vérifie que les résultats sont identiques. Pendant l’exécution du test, un compteur enregistre chaque affichage de chaque composant. Par ailleurs, esbuild mesure l’impact de chaque solution sur un fichier compressé et minifié, en excluant React, car chaque option entraîne le même coût.

Les huit concurrents sont :

  • lifted useState transmis via des props
  • Context combiné avec useReducer
  • Redux Toolkit
  • Zustand
  • Jotai
  • Valtio
  • MobX
  • un store écrit manuellement basé sur useSyncExternalStore, sans dépendances

Tous les huit respectent le contrat commun, donc toute différence observée concerne le coût et non la justesse :

✓ 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)

Les versions de la bibliothèque et l’environnement de exécution utilisés pour les mesures sont indiqués ici, ainsi que le répertoire contenant les huit implémentations, les tests partagés et les deux scripts de mesure :

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.

Deux choix pour garantir une comparaison équitable

Ces deux éléments proviennent de bugs rencontrés dans des applications réelles, et non des règles d’éthique en matière de benchmarking.

Tout d’abord, chaque implémentation crée son stockage à l’intérieur d’un composant plutôt qu’au niveau du module. Ainsi, chaque application lancée démarre véritablement à zéro, et aucun état ne s’écoule entre les exécutions des tests.

Deuxièmement, l’icône de thème n’a qu’un rôle purement passif. Elle s’abonne à une valeur qui n’est jamais modifiée par un clic, de sorte que tout rendu qu’elle effectue pendant le test constitue un travail inutile dont on ne peut blâmer que le pattern d’état.

Ce qui est délibérément exclu du champ d’application

Le concours ne concerne que l’état client partagé. TanStack Query est exclu car il gère un cache de données serveur, ce qui constitue un problème distinct avec des règles différentes. L’état URL est également exclu car la barre d’adresses relève du routeur. Votre application a probablement besoin des deux, mais aucun ne concourt dans ce concours en particulier.

Surprise numéro un : la taille du code est presque identique

Avant les chiffres intéressants, un chiffre ennuyeux mais qui mérite attention. Les huit implémentations vont de 39 à 58 lignes. L’option la plus complexe, Redux Toolkit avec 58 lignes, n’est que de dix-neuf lignes plus longue que l’approche la plus simple, lifted state avec 39 lignes. À cette échelle, l’argument lié aux codes génériques, qui domine tant de discussions sur la gestion de l’état, ne représente que dix-neuf lignes. Les différences réelles se trouvent ailleurs et ne deviennent visibles qu’avec des outils d’analyse.

Le classement des performances de rendu

Voici le nombre de fois où chaque composant a été rendu au cours des cinq clics, y compris le chargement initial :

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

Trois histoires distinctes émergent de ces colonnes.

Un état partagé et un Context qui rérend tout

Avec useState partagé et un seul Context, chaque composant se rérend à chaque clic. L’icône de thème s’est affichée six fois bien que sa valeur n’ait jamais changé, et le bouton banane s’est affiché à chaque clic sur une pomme. C’est le mécanisme concret derrière l’avertissement courant selon lequel Context n’est pas un gestionnaire d’état. useContext inscrit un composant à la valeur complète du contexte, de sorte que chaque fois que cette valeur change, tous les composants qui l’utilisent se rérendent. Placer tout votre état dans un seul contexte revient en fait à utiliser un état partagé avec une couche supplémentaire.

La solution habituelle consiste à répartir l’état sur plusieurs contextes ou à mémoriser les composants qui l’utilisent, mais cela n’a pas été testé ici. Cette section illustre le Context tel qu’il est le plus souvent utilisé : un fournisseur contenant une seule valeur.

Quatre API, un résultat identique

Redux Toolkit, Zustand, MobX ainsi que le store écrit manuellement produisent exactement la même colonne : chaque composant s’affiche une fois lors du montage et à nouveau uniquement lorsque la partie de l’état qu’il lit change réellement. Leurs API ne se ressemblent pas du tout, mais leur comportement en temps de exécution est identique, car les quatre reposent sur la même idée de base. Les composants écoutent une partie spécifique de l’état plutôt que l’ensemble du store, et ils ne sont notifiés que lorsque cette partie change. Pour en savoir plus sur le fonctionnement de ce mécanisme de sélection et de vérification d’égalité dans l’une de ces bibliothèques, consultez comment React Redux décide quand re-render.

Chiffres suspectement bas de Valtio

La colonne de Valtio ressemble au premier abord à un compteur défectueux : deux mises à jour pour un bouton cliqué trois fois. Les comptes sont cependant corrects. Valtio regroupe les notifications de modification dans la file d’attente des micro-tâches, de sorte que plusieurs mises à jour rapides et synchrones se résument en une seule mise à jour par composant, et les valeurs finales restent justes.

Il existe toutefois une précision importante. Une personne qui clique à une vitesse normale génère une mise à jour par clic, car chaque clic s’achève avant que le suivant ne commence. L’avantage ne se manifeste que lorsque les mises à jour arrivent en série, comme c’est le cas avec les messages WebSocket, les données en flux ou les événements de glisser-déposer. Si votre application présente ce profil, cette colonne mérite une attention particulière.

Mise à jour supplémentaire constante de Jotai

Jotai a rendu chaque composant une fois de plus que le groupe basé sur des sélecteurs, y compris deux rendus pour l’icône du thème inactif. Sa granularité est correcte, car les composants ne réagissent toujours qu’aux éléments qu’ils utilisent, et ce rendu supplémentaire est uniforme dans toutes les colonnes. Ce schéma indique un problème dans la séquence d’initialisation avec le Store Provider plutôt qu’une fuite de souscription, mais la cause exacte n’a pas été identifiée. Il convient de le considérer comme une question en suspens plutôt que comme un jugement contre Jotai.

Évaluation de la taille du bundle

Ceci montre ce que chaque schéma ajoute à un bundle compressé, en comptant à la fois le code propre au schéma et sa bibliothèque, avec React traité comme un élément externe :

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

La solution la plus lourde est 33 fois plus volumineuse que la plus légère. En d’autres termes, Redux Toolkit et MobX ensemble pèsent 23,9 KB, tandis que les six autres solutions combinées totalisent 9,2 KB.

Ce qui est intéressant, c’est la manière dont cette liste interagit avec le tableau de bord de rendu. Seules deux solutions combinent un comportement de rendu parfait avec une taille inférieure à un kilobyte : Zustand et le store écrit manuellement. Redux Toolkit atteint la même performance de rendu avec 10,7 KB, et MobX avec 13,2 KB. Cela ne constitue pas automatiquement un inconvénient pour eux ; c’est plutôt un coût, et un coût n’a de sens que si l’on sait ce qu’il permet d’obtenir.

Trois choix pratiques et leur coût respectif

Pour gérer l’état client partagé dans une application typique, les mesures indiquent trois outils qui méritent d’être considérés.

Zustand comme choix par défaut

Zustand offre une granularité de rendu parfaite avec 0,7 KB et 54 lignes, et son API ne nécessite presque aucune explication pour un nouveau collègue : il s’agit d’un hook qui prend un sélecteur. Dans ce test, il a reproduit le comportement en temps de exécution de Redux avec une taille environ un quinzième inférieure.

Ce résultat dépend d’une règle essentielle : choisir toujours un slice. Une appelation comme useCart(s => s) inscrit le composant à l’ensemble du store, ce qui reproduit le problème lié au Context. L’efficacité provient de l’écriture de sélecteurs ciblés, et non du nom de la bibliothèque. Si un sélecteur renvoie un nouvel objet ou un nouvel array à chaque appel, il faut également utiliser un outil d’égalité superficielle, sinon chaque mise à jour semblera être un changement.

Un store useSyncExternalStore écrit manuellement

Cette option est celle qui rend l’expérience la plus convaincante. La mise en œuvre complète, y compris le stockage, tient en 58 lignes, ajoute 0,5 KB, correspond exactement à la colonne de rendu de Redux et ne comporte aucune dépendance à auditer ou à mettre à jour. useSyncExternalStore est la fonction primitive fournie par React lui-même pour s’abonner en toute sécurité à des stores externes, même en situation de rendu concurrentiel. Pour les auteurs de bibliothèques ou les équipes préférant un nombre minimal de dépendances, cela suffit. Le compromis réside dans le fait que vous êtes responsable du code : les outils de développement, les middleware et la persistance doivent être développés par vous si vous en avez besoin.

Valtio pour des mises à jour intermittentes

Le regroupement des micro-tâches de Valtio était mesurable, réel et unique parmi ces concurrents, et 2,7 KB représente un prix raisonnable pour cela. Écrire une mutation directe comme state.apples++ et voir exactement les composants appropriés se mettre à jour semble presque trop pratique, mais le comptage des rendus confirme que ce comportement est réel.

Pourquoi les autres perdent cette compétition sans être mauvais

La forme du test est importante, et elle favorise un état petit et plat. Les autres options possèdent des forces que cette application ne peut pas exploiter :

  • Les 10,7 KB de Redux Toolkit compensent les outils de développement, le débogage par voyage dans le temps, les middleware ainsi que les conventions qui fonctionnent bien dans de grandes organisations. Avec cinquante développeurs sur une même base de code, ces conventions constituent le véritable produit.
  • Le modèle observable de MobX est le plus performant dans les codes de domaine riches en classes, ce que cette application ne possède pas.
  • Le graphique atomique de Jotai se distingue lorsque l’état dérivé devient profond et interconnecté, et un total dérivé unique peine à l’atteindre.
  • Le contexte reste l’outil idéal pour des valeurs qui changent rarement, comme le thème, la localisation ou la session. Ironiquement, c’est précisément dans la colonne du thème que ses performances ont été les pires, car l’état du panier partageait son fournisseur.
  • L’état soulevé reste approprié pour les états qui ne sont pas partagés. Il a simplement perdu parce que ce concours porte spécifiquement sur le partage.
  • Une règle de conception qui n’est pas une question de goût

    Chaque implémentation présentée ici a créé son store à l’intérieur d’un composant. Un store défini au niveau du module survit au démontage, ce qui permet à un panier d’achats de persister après un déconnexion et de saluer la prochaine personne se connectant sur le même appareil. Cela entraîne également une fuite d’état entre les tests et entre les requêtes lors du rendu serveur. Si vous devez retenir une seule règle de revue de code de cette comparaison, ce sera celle-ci : limitez l’accès aux stores au arbre des composants, généralement en les créant dans un fournisseur, sauf si vous souhaitez délibérément en faire des entités à durée de vie globale.

    Exécuter le même test dans votre propre application

    Vous pouvez reproduire cette mesure pour votre propre état en une après-midi :

    1. Choisissez l’élément d’état partagé le plus controversé dans votre application et créez autour de lui un ensemble de quatre composants : deux composants pour écrire, un pour lire une valeur dérivée, et un composant inactif en tant que témoin.
    2. Ajoutez un compteur de rendu à chaque composant. Un objet au niveau du module ainsi qu’une appel à track() dans le corps de chaque composant nécessitent environ dix lignes de code. Le comptage effectué par l’observateur extérieur représente la « taxe » liée à l’utilisation de Context, mesurée de manière précise plutôt que supposée.
    3. Évaluez chaque solution potentielle à l’aide de esbuild --bundle --minify, en indiquant React comme bibliothèque externe. Cela ne prend que quelques secondes et ajoute une colonne indiquant la taille en kilooctets aux résultats.
    4. Si vous utilisez Context et que l’observateur extérieur provoque un nouveau rendu, soit divisez le contexte, soit passez à un stockage basé sur des sélecteurs, puis relancez le compteur et incluez les valeurs avant et après dans votre pull request.
    5. Reprenez toute l’opération une fois que React Compiler fait partie de votre processus de compilation, car la mémorisation automatique peut modifier considérablement les données transférées entre composants.

    Questions en suspens

    Deux threads restent non gérés. Le plus petit est la cause de la rérenderisation supplémentaire de Jotai. Le plus grand est React Compiler. La ligne « lifted-state » affiche 6/6/6/6 précisément parce que rien à l’intérieur n’est mémorisé, or la mémorisation est exactement ce que le compilateur automatise. Vérifier si cela permet de mettre cette ligne en conformité avec les stores basés sur des sélecteurs dans du code de production réel, et non seulement dans une démo, constitue l’expérience suivante évidente.

    Ce que représentent les comptes de rérenderisation et les tailles des bundles

    • À petite échelle, les différences de code de base entre les bibliothèques de gestion d’état sont négligeables ; c’est le comportement de rérenderisation et la taille des bundles qui diffèrent réellement.
    • Un seul Context contenant un état en changement provoque une nouvelle rérenderisation de tous les consommateurs, y compris des composants qui ne lisent jamais la valeur modifiée.
    • Les stores basés sur des sélecteurs, qu’il s’agisse de Redux Toolkit, Zustand, MobX ou d’un store personnalisé useSyncExternalStore, suivent tous le même modèle de rendu efficace.
    • Zustand ainsi qu’un store écrit manuellement offrent ce comportement en moins d’un kilooctet ; les bibliothèques plus lourdes gagnent en taille grâce à des outils et des conventions, et non au processus de rendu.
    • Le regroupement des mises à jour de Valtio s’avère utile pour des flux d’actualisation intermittents plutôt que pour des clics ordinaires.
    • Créez les stores à l’intérieur de l’arbre des composants afin que l’état ne survive pas à la session qui l’a généré.