Accueil / Articles / Évaluer les gestionnaires d’état de React : comparaison du nombre de rendus et du coût des bundles

Évaluer les gestionnaires d’état de React : comparaison du nombre de rendus et du coût des bundles

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

2227 mots

La question du choix du gestionnaire d’état React revient constamment, et le débat repose généralement sur des opinions plutôt que sur des mesures. Une approche plus utile consiste à développer la même petite fonctionnalité avec chaque pattern courant, à maintenir le comportement constant grâce à un ensemble de tests commun, et à compter ce qui diffère réellement : la fréquence à laquelle les composants se re-rendernent et le nombre de kilooctets ajoutés par chaque option. Cet article présente un tel experiment avec huit patterns, explique pourquoi les chiffres sont ce qu’ils sont, et vous fournit une méthode reproductible pour mener le même test avec votre propre gestionnaire d’état.

Comment l’experiment est organisé

L’application de test est délibérément très simple : un panier d’achat avec des boutons pour une pomme et un banane, un total en temps réel ainsi qu’un insigne de thème dont la valeur ne change jamais. Chaque implémentation est soumise au même test comportemental, qui clique sur la pomme trois fois et sur le 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 chacune des options 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

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

✓ 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 d’exécution utilisés pour les mesures sont indiqués ici, ainsi que le répertoire contenant les huit implémentations, le test commun 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 solutions proviennent de bugs rencontrés dans des applications réelles, et non de 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 décoratif. 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.

Qu’est-ce qui est délibérément hors champ d’application

Le concours ne couvre que l’état client partagé. TanStack Query est absent car il gère un cache de données serveur, ce qui constitue un problème distinct relevant de règles différentes. L’état URL est également absent 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 n°1 : la taille du code est presque identique

Au préalable des chiffres intéressants, un chiffre ennuyeux mais qui mérite attention. Les huit implémentations comptent entre 39 et 58 lignes de code. 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 d’état, se résume à dix-neuf lignes. Les différences significatives se trouvent ailleurs et ne deviennent visibles qu’avec des outils d’analyse.

Le classement des rendus

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 tendances distinctes ressortent de ces données.

Lifted state et Context rérendent tout

Avec le useState déplacé et un seul Context, chaque composant se 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 là le mécanisme concret derrière l’avertissement courant selon lequel Context n’est pas un gestionnaire d’état. useContext abonne un composant à la valeur complète du contexte, de sorte que chaque fois que cette valeur change d’identité, tous les composants qui l’utilisent se rendent. Placer tout votre état dans un seul contexte revient en fait à utiliser un état déplacé 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é mesuré ici. Cette présentation illustre 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 le même résultat : 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 fondamentale. Les composants écoutent une partie spécifique de l’état plutôt que tout le 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.

Les chiffres étonnamment 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 à vitesse normale génère une mise à jour par clic, car chaque clic s’achève avant que le suivant ne commence. L’avantage n’apparaît 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 lié à 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.

L’évaluation de la taille du bundle

Ceci représente ce que chaque schéma ajoute à un bundle compressé, en comptant à la fois le code propre au schéma et sa bibliothèque, React étant considéré comme 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

L’option la plus lourde est 33 fois plus volumineuse que l’option la plus légère. En d’autres termes, Redux Toolkit et MobX ensemble pèsent 23,9 KB, tandis que les six schémas restants totalisent 9,2 KB.

Le point intéressant réside dans la manière dont cette liste interagit avec le tableau de bord de rendu. Seuls deux modèles combinent un comportement de rendu parfait avec une empreinte inférieure à un kilooctet : Zustand et le store manuel. Redux Toolkit atteint la même performance de rendu avec 10,7 KB, tandis que MobX en nécessite 13,2 KB. Cela ne constitue pas automatiquement un inconvénient pour eux ; c’est plutôt un coût, et ce coût n’a de sens que lorsque 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 seulement 54 lignes de code ; son API ne nécessite presque aucune explication pour un nouveau collaborateur : il s’agit d’un hook qui prend en paramètre un sélecteur. Dans ce test, il a égalé le comportement en temps de exécution de Redux avec environ un quinzième de la taille.

Ce résultat dépend d’une seule règle : il faut toujours sélectionner un sous-ensemble. Une appelation telle que useCart(s => s) inscrit le composant à l’ensemble du stockage, ce qui reproduit le problème lié au Context. Une approche efficace consiste à écrire des sélecteurs ciblés, et non au nom de la bibliothèque. Si un sélecteur renvoie un nouvel objet ou un nouveau tableau à chaque appel, il vous faut également un outil d’égalité superficielle, sinon chaque mise à jour semblera être un changement.

Un stockage 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 pour 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 coût 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 avantages 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 domaines 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é, alors qu’un total dérivé unique ne l’atteint à peine.
  • 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

    Toute 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ésinstallation, 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 à partir 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 une 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. Sélectionnez 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.
  • 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 avec précision plutôt que devinée.
  • Mesurez 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.
  • 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.
  • Répétez 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éaffichage supplémentaire de Jotai. Le plus grand correspond au 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 c’est justement la fonction de mémorisation que le compilateur automatise. Vérifier si cela permet d’aligner cette ligne 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.

    Points clés

    • À petite échelle, les différences de code générique entre les bibliothèques de gestion d’état sont négligeables ; c’est le comportement de rendu et la taille du bundle qui diffèrent réellement.
    • Un seul Context contenant un état en changement provoque un réaffichage 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, aboutissent tous au même schéma de rendu efficace.
  • Zustand et une boutique écrite à la main permettent d’obtenir ce comportement avec moins d’un kilooctet ; les bibliothèques plus lourdes gagnent en taille grâce à des outils et des conventions, et non à du rendu.
  • Le regroupement des opérations de Valtio s’avère utile pour des flux d’actualisation irréguliers plutôt que pour des clics ordinaires.
  • Créez des boutiques à l’intérieur de l’arbre de composants afin que l’état ne survive pas au-delà de la session qui l’a généré.