Pourquoi Redux ou Zustand sont destinés à de nombreux développeurs et non à de nombreux lecteurs
Le nombre de lecteurs n’est pas une raison suffisante pour disposer d’un magasin client. Ce sont les multiples écrivains qui partagent des invariants — comme un panier d’achat — qui justifient l’utilisation de réducteurs, de fonctionnalités de rejouement et de tests directs.
Quatre justifications courantes pour l’utilisation de Redux, Zustand ou d’autres bibliothèques similaires ont déjà été démontées : le forage de propriétés, les mises à jour côté client, la diffusion automatique vers tous les abonnés, et le Context comme solution par défaut plus sûre. React couvre déjà ces cas sans avoir besoin d’un stock supplémentaire.
Aucun de ces arguments ne pose la question qui détermine réellement le choix d’une bibliothèque. Ce n’est pas le nombre de composants qui *lisent* une valeur, mais plutôt le nombre d’endroits non liés qui peuvent *la modifier*, et la question de savoir si ces modifications doivent rester cohérentes entre elles par la suite.
Un interrupteur de thème dispose d’un seul écrivain — un seul contrôle, une seule appel à setTheme. C’est pourquoi aucune bibliothèque n’était nécessaire, quel que soit le nombre d’écrans qui utilisaient le résultat. Un panier d’achat est un cas différent : plusieurs écrivains agissent dans des directions différentes, ce qui nécessite un exemple distinct.
Un écrivain contre plusieurs
Un panier apparaît sur la fiche produit sous forme d’option « Ajouter au panier », dans un panneau de configuration en tant que curseur de quantité, sous forme de lien de suppression, dans un champ de coupon qui recalcule les totaux, et en tant que bouton pour vider le panier. Cinq fichiers, cinq sites de mutation, chacun capable de modifier le même état, sans que aucun ne soit au courant des quatre autres.
Comparez avec le drapeau de thème : un bouton, un écrivain. Chaque lecteur affiche simplement la dernière valeur. Aucune coordination, car une seule main fait tourner la roue.
Le panier conserve des invariants — trois au total. Un invariant est un fait qui doit rester vrai après toute opération. Pour ce panier : le total correspond toujours à la somme du prix de l’article multiplié par la quantité, moins la remise en vigueur ; la quantité ne devient jamais négative ; deux articles ne partagent jamais le même SKU — ajouter une autre unité d’un produit existant augmente simplement la quantité sans créer de ligne dupliquée.
Cinq écrivains, trois faits que tout écrivain doit préserver. C’est le problème que Redux, Zustand ou MobX sont là pour résoudre, et cela n’a rien à voir avec le nombre de composants qui ne font que lire le panier.
Que se passe-t-il lorsque cinq écrivains indépendants modifient les mêmes trois faits sans qu’aucun mécanisme ne les contrôle ?
Ce qui se dégrade en l’absence d’un endroit discipliné
Imaginez que trois de ces cinq points de mutation, chacun écrit dans le style choisi par son auteur, modifient directement l’objet du panier :
// components/AddToCartButton.jsx
function addItem(item) {
cart.items.push(item);
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/QuantityStepper.jsx
function changeQuantity(sku, newQty) {
const item = cart.items.find(i => i.sku === sku);
item.quantity = newQty;
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/CouponInput.jsx
function applyCoupon(code) {
cart.discount = getDiscountFor(code);
}
getDiscountFor sert simplement à rechercher un code pour obtenir le numéro de la remise correspondante : on entre le code, on obtient le montant de la remise. AddToCartButton.jsx et QuantityStepper.jsx recalculent tous deux cart.total, tandis que CouponInput.jsx ne le fait pas. L’invariant selon lequel « le total égale la somme moins la remise » est alors violé, et le système ne réagit pas — aucune règle n’était responsable de cela. Il s’agissait d’une convention selon laquelle trois fichiers devaient respecter cette règle, mais l’un d’eux l’a oubliée.
Un réducteur élimine cette dépendance en imposant que toute modification passe par une seule fonction. Le réducteur prend l’état actuel ainsi qu’une action (un objet simple décrivant ce qui s’est produit) et renvoie un nouvel état. Il ne modifie jamais directement l’objet existant.
// store/cartReducer.js
function computeTotal(items, discount) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return subtotal * (1 - discount);
}
export function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const items = [...state.items, action.item];
return { ...state, items, total: computeTotal(items, state.discount) };
}
case 'CHANGE_QUANTITY': {
const items = state.items.map(i =>
i.sku === action.sku ? { ...i, quantity: action.qty } : i
);
return { ...state, items, total: computeTotal(items, state.discount) };
}
case 'APPLY_COUPON': {
const discount = getDiscountFor(action.code);
return { ...state, discount, total: computeTotal(state.items, discount) };
}
}
}
computeTotal s’exécute dans chaque branche du switch — non pas parce que chaque auteur s’en souvient, mais parce qu’utiliser une seule fonction et un seul switch rendrait l’omission délicate. Les trois anciens auteurs directs décrivent désormais les événements au lieu de les exécuter :
// components/AddToCartButton.jsx
import { useCartDispatch } from '../store/CartContext';
function AddToCartButton({ item }) {
const dispatch = useCartDispatch();
return <button onClick={() => dispatch({ type: 'ADD_ITEM', item })}>Add to Cart</button>;
}
export default AddToCartButton;
// components/QuantityStepper.jsx
import { useCartDispatch } from '../store/CartContext';
function QuantityStepper({ sku, qty }) {
const dispatch = useCartDispatch();
return (
<input
type="number"
value={qty}
onChange={e => dispatch({ type: 'CHANGE_QUANTITY', sku, qty: Number(e.target.value) })}
/>
);
}
export default QuantityStepper;
dispatch provient d’un petit fichier CartContext.jsx qui associe useReducer (le hook intégré qui retourne l’état ainsi que le mécanisme de dispatch) au Context, permettant ainsi à n’importe quel composant d’accéder à ce dispatch. Ni les boutons ni les curseurs ne écrivent plus cart.total = .... Aucun d’eux n’a besoin de savoir que computeTotal existe.
Les lectures restent ouvertes. Les composants peuvent toujours lire cart.total pour afficher les prix, les résumés de paiement ou des messages indiquant des articles non enregistrés. Seules les écritures suivent un seul chemin : déclencher une action et laisser le réducteur générer l’état suivant. L’invariant réside donc en ce point critique et non chez celui qui l’appelle par hasard.
Soyez précis : une seule fonction d’écriture ne rend pas la règle correcte — elle assure plutôt une application cohérente. Si computeTotal oublie lui-même la remise, chaque chemin d’écriture produirait toujours le même total erroné. C’est encore mieux que la version dispersée : un bug uniforme est facile à isoler. Un bug qui n’apparaît que lorsque un fichier oublie une ligne est beaucoup plus difficile à trouver, car les chemins corrects et incorrects ont l’air identiques jusqu’à ce que le cas manquant soit rencontré.
Lorsque le chemin du coupon oublie de recalculer, l’interface peut encore sembler correcte jusqu’à ce qu’un changement de quantité ultérieur force un nouveau calcul — ou jusqu’au moment où la page de paiement affiche un total qui ne correspond plus aux éléments de la commande. C’est précisément ce caractère intermittent qui explique pourquoi les conventions échouent : le problème est suffisamment rare pour passer inaperçu lors des clics occasionnels, mais suffisamment fréquent pour causer des erreurs réelles.
Centraliser la règle ne supprime pas la nécessité d’une logique de computeTotal rigoureuse. Elle garantit cependant que chaque chemin d’écriture utilise la même fonction, de sorte qu’une formule erronée reste cohérente et donc identifiable. Les mises à jour dispersées dissimulent la même erreur derrière l’illusion qu’il « fonctionne pour l’essentiel ».
Un seul chemin d’écriture discipliné a de la valeur en soi. Quels autres avantages apporte-t-il lorsque quelque chose tombe en panne par la suite ?
Chaque action devient une capture reproductible
Deux propriétés de réducteur combinées produisent un résultat plus fiable qu’un simple fichier de journal.
Tout d’abord, les actions sont serialisables : il s’agit de JSON simple sans fonctions, d’instances de classes ou de références cachées — uniquement { type: 'APPLY_COUPON', code: 'SAVE10' }. Ensuite, cartReducer est pur : un état identique associé à des actions identiques produit toujours un état suivant identique, sans aucune lecture externe.
Ensemble, rejouer une séquence depuis le même point de départ mène toujours au même résultat final. C’est ce déterminisme que Redux DevTools utilise réellement. Il ne se contente pas d’afficher qu’un événement s’est produit ; il stocke la liste des actions en tant que données et recalcule le snapshot exact après chaque étape sélectionnée lorsqu’on clique dessus.
Prenons le bug précédent : le total est complètement erroné après l’application d’un coupon puis la suppression d’un article. Avec les DevTools ouverts, la séquence affiche ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. Après APPLY_COUPON, le total semble correct ; après REMOVE_ITEM, il ne l’est plus. Ce bug a une cause précise : la branche REMOVE_ITEM — sans avoir besoin d’ajouter des console.log ou de reprendre manuellement la session de l’utilisateur.
Cet avantage n’est pas identique d’une bibliothèque à l’autre. Redux DevTools est la version originale et mature. Le middleware devtools de Zustand intègre set() dans la même extension, de sorte que les mises à jour apparaissent sous forme d’actions nommées. MobX modifie généralement les observables directement via des proxies plutôt que par le biais d’une série d’actions serialisables ; ses outils mettent donc l’accent sur les graphes de réaction — indiquant quelles calculs ont été exécutés à nouveau et pourquoi — plutôt que sur un journal complet des déplacements dans le temps.
Replay a besoin d’une fonction déterministe qui associe toujours les mêmes entrées aux mêmes sorties. Quels autres avantages cela offre-t-il en dehors de l’extension navigateur ?
Un réducteur n’est qu’une fonction que l’on peut appeler
cartReducer(startState, action) est une simple appel : les arguments entrent, l’état suivant complet en sort. Le tester ne nécessite ni navigateur, ni clics, ni composant rendu.
// store/cartReducer.test.js
import { cartReducer } from './cartReducer';
test('APPLY_COUPON recomputes total', () => {
const startState = {
items: [{ sku: 'A1', price: 20, quantity: 2 }],
discount: 0,
total: 40,
};
const nextState = cartReducer(startState, { type: 'APPLY_COUPON', code: 'SAVE10' });
expect(nextState.discount).toBe(0.10);
expect(nextState.total).toBe(36); // 40 minus 10 percent
});
Le test s’achève en quelques millisecondes sans bibliothèque de rendu. Il détecte directement le bug précédent : si APPLY_COUPON saute computeTotal, nextState.total restera toujours égal à 40 et l’assertion échouera sur la branche défectueuse plutôt qu’en raison d’un manque de cohérence vague dans l’interface.
La même logique qu’un setteur useState à l’intérieur d’un gestionnaire de clic ne peut pas être testée de cette manière — et la raison n’est pas JSX :
// hooks/useCoupon.js
import { useState } from 'react';
function useCoupon() {
const [discount, setDiscount] = useState(0);
const applyCoupon = code => setDiscount(getDiscountFor(code));
return { discount, applyCoupon };
}
export default useCoupon;
Appeler useCoupon() depuis un simple test provoque une erreur. Des hooks comme useState ne fonctionnent que pendant le rendu de React (ou à l’intérieur d’un autre hook appelé lors du rendu), en étant suivis par rapport à cette instance de composant. Tels sont les règles des hooks : des appels inconditionnels dans le même ordre à chaque rendu, car React compare l’état des hooks en fonction de leur position d’appel et non de leur nom. Si l’on saute un hook lors de certains rendus, la traçabilité devient désynchronisée.
applyCoupon n’arrive pas non plus à retourner un état utile de la même manière que cartReducer. setDiscount retourne undefined. Il planifie un re-render ; la nouvelle valeur n’apparaît que lors du prochain exécution du corps du composant. Il n’y a pas de valeur de retour à vérifier — le mécanisme consiste à « demander à React de re-render », et non à « calculer puis retourner une valeur ».
Le tester signifie afficher quelque chose et lire ce qui apparaît à l’écran :
// CartSummary.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import CartSummary from './CartSummary';
test('applying coupon updates the displayed total', () => {
render(<CartSummary />);
fireEvent.click(screen.getByText('Apply SAVE10'));
expect(screen.getByTestId('total')).toHaveTextContent('36');
});
Même fait à tester — le total après application d’un coupon — mais l’assertion vérifie le texte affiché, après un re-render complet et un clic simulé.
Une solution intermédiaire est renderHook de React Testing Library : on exécute un hook sans JSX ni clics, puis on examine result.current. Il nécessite néanmoins le rendu de test de React via act, qui applique les mises à jour et les effets avant les assertions. Les outils de scaffolding restent nécessaires car l’état du hook se trouve toujours à l’intérieur d’une instance montée (même minimale). cartReducer n’avait besoin de rien de tout cela ; il n’a jamais été lié à un composant.
La testabilité concerne les couplages. À quoi ressemble la même idée lorsqu’on choisit où s’arrête un store et où commence un autre ?
Où se situe réellement la frontière
Les invariants initiaux répondent également à une autre question : où un store doit s’arrêter et le suivant commencer.
Les éléments items, discount et total font partie d’un même ensemble car ils sont liés entre eux : modifier l’un peut rendre invalides les informations qui dépendent des autres. Le paramètre theme ne doit pas figurer dans ce réducteur, car rien lié à theme ne détermine cart.total, et rien lié au panier ne détermine theme. Il s’agit d’états indépendants qui coexistent simplement au sein d’une application.
Un produit réel génère généralement plusieurs de ces ensembles de règles, chacun ignorant les autres :
cartStore → items, discount, total
authStore → user, session, permissions
themeStore → theme
notifyStore → toasts, unread count
Dans Redux, cela se traduit par des slices — un réducteur pour chaque partie de l’arborescence — regroupés sous une racine sans que leur logique ne soit fusionnée. Dans Zustand, il s’agit de fonctions create() distinctes (useCartStore, useAuthStore, …). Dans MobX, il s’agit de classes observables séparées avec leurs propres actions et invariants, sans réducteur commun.
Un composant peut lire plusieurs stores lors d’une même rendu. Un résumé de panier peut par exemple lire cartStore.total et authStore.user.currency pour formater l’argent. Les lectures peuvent se combiner librement. Les écritures, en revanche, ne doivent pas croiser leurs domaines : le réducteur du panier ne doit pas accéder à l’état d’authentification, et inversement.
Si modifier un store oblige également à modifier un autre pour que la vérité d’un fait reste vraie — comme le changement de devise qui force un rec calcul du total du panier — les limites ont été mal définies. Soit ces éléments appartiennent à un seul store coordonné, soit ils nécessitent un pont explicite dont la fonction est de les maintenir synchronisés, et non une dépendance implicite non documentée.
Le choix de la bibliothèque reste important pour les conventions d’équipe, le middleware et la taille de l’écosystème, mais ce sont des facteurs secondaires. Le critère principal est structurel : plusieurs auteurs associés à des invariants partagés. Sans cette structure, le Context, les reducers et les props de React couvrent déjà la plupart des cas où l’on a besoin d’accès global aux données. Avec cette structure, un store dédié — Redux Toolkit, Zustand, MobX ou un useReducer partagé avec soin — remplit sa fonction en centralisant toutes les règles en un seul endroit.
Les équipes découvrent souvent cette limite tardivement, après que le système de panier, le flux de réservation ou la matrice des permissions aient déjà développé cinq points de mutation. Modifier un reducer reste moins coûteux que de déboguer des totaux intermittents. Plus tôt l’invariant est nommé dans le code, moins l’interface utilisateur doit compenser par des corrections ad hoc.
Si une fonctionnalité n’a qu’un seul auteur et ne comporte aucune règle inter-domaines, conservez-la au niveau local. Si elle a de nombreux auteurs et des règles qui doivent être respectées par tous, donnez à ces écritures une seule issue.
La réponse réelle
Des analyses précédentes ont montré qu’un simple comptage des lecteurs ne justifie jamais la création d’une bibliothèque — React gère déjà ces cas. Cet article aborde l’autre aspect : le nombre de lieux indépendants pouvant écrire, et le fait que ces écritures doivent conserver les mêmes informations, constituent la véritable épreuve.
Un drapeau de thème échoue à ce test partout : un seul auteur, pas d’invariant inter-domaines, rien à coordonner. Un chariot le passe partout en même temps : cinq auteurs, trois faits que chacun d’eux peut modifier, un réducteur qui transforme « cinq personnes doivent se souvenir de la règle » en « la règle s’applique inconditionnellement », ainsi que deux effets secondaires utiles — le débogage sous forme de chronologie reproductible au lieu de devinements, et des tests qui appellent une fonction plutôt que d’afficher une interface pour extraire un nombre.
C’est là la ligne de démarcation. Pas le nombre de composants. Le nombre d’auteurs, et ce qui doit rester vrai pour tous.
En d’autres termes : les bibliothèques de gestion de l’état côté client sont des outils conçus pour coordonner les écrivains d’état, et non pour distribuer les lecteurs. La gestion de la diffusion des lectures relève du rôle de React. La coordination des écrivains d’état — ainsi que les invariants qu’ils doivent respecter — c’est là que Redux, Zustand, MobX ou une bibliothèque équivalente deviennent la solution appropriée plutôt qu’une simple habitude.