Pourquoi j’ai remplacé Redux par 30 lignes de Zustand


Introduction
Les stores sont devenus un standard incontournable dans la construction d’architectures frontend modernes. Que ce soit avec Angular, Vue ou React, ils offrent une solution élégante et performante pour gérer des états complexes et partagés au sein d’une application.
Malgré leur omniprésence, les concepts derrière un store — et notamment leur utilité réelle — restent parfois flous pour de nombreux développeurs. On sait qu’il faut "gérer un état global", on entend parler de Redux, de flux unidirectionnel, de reducers… Mais à quel moment ce besoin apparaît il réellement ? Et surtout : comment éviter de tomber dans une complexité inutile ?
Dans cet article, je te propose de démystifier les notions de "store" et de "state management" dans l’écosystème React, en m’appuyant sur une solution légère et moderne : Zustand. Nous allons partir d’un besoin concret — un panier d’achat pour une boutique en ligne — pour expliquer comment Zustand nous permet de construire un store minimaliste, réactif, persistant, tout en restant lisible et facile à maintenir.
Pourquoi a-t-on besoin d’un store ?
Dans une application React, tout commence très simplement : on utilise useState
, useEffect
, et les composants s’échangent des données via des props. Tant que notre état est local et simple, ce système fonctionne parfaitement.
Mais les choses se compliquent rapidement dès qu’un état doit être partagé entre plusieurs composants éloignés, voire sur toute l’application. C’est souvent le cas :
d’un panier d’achat,
de l’état utilisateur (authentification),
d’un thème ou de préférences globales,
d’un système de notifications, etc.
Il devient alors tentant de remonter l’état "plus haut" dans l’arbre de composants, ou de créer un context
. Mais très vite, cette approche devient lourde, difficile à tester, et source de re-renders (recalcul de l’affichage) inutiles.
C’est là qu’un store prend tout son sens. Il permet :
de centraliser l’état,
de simplifier l’accès à cet état (depuis n’importe quel composant),
et surtout de contrôler précisément les mutations, pour garantir une logique cohérente et une meilleure maintenance à long terme.
Zustand : une alternative simple à Redux
Parmi les nombreuses solutions de state management pour React, Redux reste une référence… mais c’est aussi un monstre de complexité pour les besoins les plus simples. Entre les actions, les reducers, les types, les providers, les middlewares… l’investissement initial en temps ou en apprentissage n’est pas négligeable.
Nous avons l'interface utilisateur (front-end), comme illustré dans l'architecture ci-dessus. Les créateurs d'actions (action creators) garantissent que la bonne action est déclenchée pour chaque requête utilisateur. Vous pouvez voir une action comme un événement décrivant ce qui s'est passé dans l'application (ex: un clic sur un bouton ou une recherche).
Les dispatchers aident à envoyer ces actions vers le store. Ensuite, les réducteurs (reducers) déterminent comment gérer l'état. Une fonction de réduction modifie l'état en prenant l'état actuel et l'objet d'action, puis retourne le nouvel état si nécessaire. Les modifications de l'état mettent à jour l'interface utilisateur.
Pour aller plus loin, je vous recommande cet article sur Redux : Les Stores Pas à Pas de Lionel ZUBER.
Voici maintenant la partie la plus excitante ! Voyez comment Zustand fonctionne avec le schéma ci-dessous :
Le composant d'interface utilisateur (UI) est directement connecté au store. Lorsqu'une modification est demandée (par exemple, un clic ou une saisie), la requête est envoyée au store, qui détermine comment mettre à jour l'état.
Une fois que le store a calculé le nouvel état, l'UI se re-dessine automatiquement avec les nouvelles données.
Contrairement à des bibliothèques comme Redux, Zustand simplifie le processus :
❌ Pas besoin de créateurs d'actions (action creators)
❌ Pas besoin de dispatchers
❌ Pas besoin de réducteurs (reducers) explicites
À la place, Zustand utilise un système d'abonnement (subscription) qui permet à l'UI de se synchroniser instantanément avec les changements d'état.
Zustand se veut minimaliste et accessible. Bien qu’il soit tout à fait capable de gérer des cas complexes, il brille surtout dans des contextes où une architecture plus légère que Redux suffit : petits projets, prototypages, ou équipes recherchant un outil rapide à prendre en main. Sa philosophie est la suivante :
"Un store, c’est juste une fonction. Rien de plus."
Pas de configuration compliquée. Pas de provider à déclarer. Pas de concepts obscurs. Juste un hook
que tu appelles là où tu en as besoin, pour lire ou modifier l’état global.
Et pourtant, Zustand reste très puissant : support natif de la persistance (localStorage
), intégration avec Redux DevTools, middlewares personnalisés, et surtout, des performances excellentes grâce au state slicing (on y revient plus tard).
Assez parlé, place à l'action !
Cas concret : un panier d’achat
Pour illustrer tout cela, j’ai construit une petite boutique en ligne. L’utilisateur peut y parcourir des produits, les ajouter à son panier, modifier les quantités, ou les supprimer. Un grand classique — et un cas d’école pour tester la gestion d’état global.
Plutôt que de passer par un useReducer
global ou un contexte, j’ai choisi d’utiliser Zustand pour gérer le panier.
Voici les fonctionnalités que le store devra prendre en charge :
Ajouter un produit au panier (en évitant les doublons)
Gérer la quantité de chaque produit
Supprimer un produit
Calculer automatiquement le total des articles et le prix
Conserver le panier dans
localStorage
Pour l’interface utilisateur, j’ai utilisé Radix UI combiné à quelques composants maison. Radix est une librairie de composants accessibles, non stylisés, qui s’intègre très bien avec Tailwind ou n’importe quel système de design. Elle permet de gagner du temps sur des éléments complexes comme les tiroirs (Sheet
), les scrolls (ScrollArea
), ou encore les dialogues, tout en gardant un contrôle total sur le rendu et le style. L’objectif ici n’est pas de se concentrer sur le design, mais d’avoir une base propre et fonctionnelle pour illustrer l’usage de Zustand dans une app React.
Quelques données produits
Pour illustrer notre exemple, on utilise un ensemble de données produits factices tirées de l’API FakeStoreAPI. C’est une source pratique et réaliste pour simuler des scénarios e-commerce : chaque produit possède un titre, une image, une description, un prix, une catégorie et une note. Ces données nous permettent de construire rapidement un front fonctionnel sans avoir à gérer un backend ou une vraie base de données. Parfait pour se concentrer sur la logique de notre store Zustand !
products.ts
:
export const fakeStoreProducts = [
{
id: 1,
title: 'Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops',
price: 109.95,
description:
'Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday',
category: "men's clothing",
image: 'https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg',
rating: { rate: 3.9, count: 120 },
quantity: 1,
},
...
]
Mise en place du store avec Zustand
Dans Zustand, un store est simplement créé avec la fonction create
. On y déclare à la fois l’état (cart
, totalItems
, etc.) et les actions pour le modifier (addToCart
, removeFromCart
, etc.).
À ce stade, on pourrait se poser une question : pourquoi maintenir totalItems et totalPrice dans le store, alors qu’ils pourraient simplement être dérivés du tableau cart à chaque rendu ?
En effet, ces valeurs peuvent être calculées à la volée à partir de cart
grâce à des méthodes comme reduce()
. Cela permettrait d’alléger le store et de réduire les risques d’incohérence entre les données.
Cependant, dans cet exemple, j’ai choisi de les inclure dans le store pour des raisons pédagogiques : cela permet de montrer comment Zustand gère la mise à jour d’un state composé de plusieurs valeurs interdépendantes. On voit aussi comment les actions comme addToCart
ou decrementQuantity
peuvent impacter simultanément plusieurs parties de l’état.
Dans une application en production, il serait tout à fait envisageable de ne stocker que le tableau cart
dans le state, et de calculer les totaux au moment de l'affichage. Cela permet d'avoir un store plus simple, plus fiable, et plus facile à maintenir.
Avant de créer le store, il est important de définir la forme que prendra notre state ainsi que les actions que nous allons y attacher
import type { FakeStoreProducts as Product } from '../types/products.tsx'
interface State {
cart: Product[]
totalItems: number
totalPrice: number
}
interface Actions {
addToCart: (Item: Product) => void
incrementQuantity: (Item: Product) => void
decrementQuantity: (Item: Product) => void
removeFromCart: (Item: Product) => void
}
La première interface décrit la structure de notre state global :
cart
: la liste des produits ajoutés (chaque élément suit le typeProduct
),totalItems
: le nombre total d’articles (ex : 5 articles dans le panier),totalPrice
: le prix cumulé de tous les produits dans le panier.
La seconde interface décrit les méthodes (ou "actions") que le store expose. Ces fonctions permettent de modifier le state :
addToCart
: ajoute un produit au panier,incrementQuantity
/decrementQuantity
: modifie la quantité d’un produit déjà présent,removeFromCart
: supprime un article du panier.
Chaque fonction reçoit un Product
en paramètre, ce qui garantit qu'on manipule des objets bien formés.
Voici une version simplifiée du fichier useCartStore.ts
:
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export const useCartStore = create(
persist<State & Actions>(
(set, get) => ({
cart: [],
totalItems: 0,
totalPrice: 0,
addToCart: (product) => { ... },
removeFromCart: (product) => { ... },
incrementQuantity: (product) => { ... },
decrementQuantity: (product) => { ... },
}),
{ name: 'cart-storage' }
)
)
En combinant les deux interfaces (State
& Actions
), on obtient un store lisible, solide et prédictible
Quelques points importants ici :
persist
permet de conserver le panier même après un refresh (stocké dans lelocalStorage
)set()
permet de modifier le stateget()
permet de lire le state actuel, ce qui est très utile pour des actions conditionnelles (ex : "est-ce que ce produit est déjà dans le panier ?")
Un exemple d’action : addToCart
et removeFromCart
Prenons une action concrète. Lorsqu’un utilisateur clique sur "Ajouter au panier", cela permet d’ajouter un produit au panier tout en mettant à jour le total des articles et le prix total.
Je commence par récupérer l’état actuel du panier avec get().cart
, puis je vérifie si le produit est déjà présent en me basant sur son id
.
Si le produit est déjà là, je mets à jour le panier avec un
map
: je repère l’élément concerné et j’incrémente simplement sa quantité.Si le produit est nouveau, je l’ajoute au tableau avec une quantité initiale.
Dans les deux cas, je mets aussi à jour les compteurs totalItems
et totalPrice
, en ajoutant 1 unité et le prix du produit.
addToCart: (product: Product) => {
const cart = get().cart
const cartItem = cart.find((item) => item.id === product.id)
if (cartItem) {
const updatedCart = cart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item,
)
set((state) => ({
cart: updatedCart,
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + product.price,
}))
} else {
const updatedCart = [...cart, { ...product, quantity: 1 }]
set((state) => ({
cart: updatedCart,
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + product.price,
}))
}
},
De même lorsqu’il clique sur “Supprimer du panier”
removeFromCart: (product: Product) => {
set((state) => ({
cart: state.cart.filter((item) => item.id !== product.id),
totalItems: state.totalItems - 1,
totalPrice: state.totalPrice - product.price,
}))
},
Là encore, aucune magie. Juste une fonction de mise à jour claire, isolée, testable. Et surtout, aucun besoin de passer l’état par des props ou des contextes.
Les composants clés de l’interface
Pour tester notre store Zustand dans un contexte concret, on a imaginé une petite interface d’e-commerce. L’application se base sur quatre composants principaux : Shop
, ProductCard
, Cart
et CartItems
. Chacun joue un rôle bien précis dans la gestion du panier.
🛍️ Shop
– La galerie produits
Le composant Shop
est la page principale qui affiche la liste des produits. On utilise ici les données factices importées depuis fakeStoreProducts
, affichées dans une grille responsive.
Chaque produit est rendu via le composant ProductCard
. Ce composant est purement visuel : il ne fait que mapper la liste d’objets en une UI agréable. La logique métier (ajouter au panier, stocker l’état...) est déléguée au store Zustand.
function Shop() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
{fakeStoreProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
📦 ProductCard
– La fiche produit
C’est ici qu’on donne vie à chaque article ! ProductCard
affiche l’image, le titre, la description et le prix. Un bouton “Add to cart” permet d’envoyer le produit au store via l’action addToCart()
exposée par Zustand.
À noter que la logique métier reste ultra légère : aucun useReducer
, useContext
, ni callback tordu. On accède directement au state global avec useCartStore
, ce qui simplifie énormément le code.
function ProductCard({ product }: productProps) {
const addToCart = useCartStore((state) => state.addToCart)
const handleClick = () => {
addToCart(product)
}
return (
<Card className="w-fit flex flex-col justify-between">
<CardContent className="p-2">
<img
src={product.image}
alt={product.title}
width={100}
height={100}
className="object-contain w-full h-40"
/>
<CardHeader>
<CardTitle>{product.title}</CardTitle>
<CardDescription>
{product.description.slice(0, 76).concat('...')}
</CardDescription>
</CardHeader>
</CardContent>
<CardFooter className="flex justify-between items-center">
<span className="font-bold text-xl">${product.price}</span>
<Button onClick={() => handleClick()}>Add to cart</Button>
</CardFooter>
</Card>
)
}
🛒 Cart
– L’icône panier et le récapitulatif
Le composant Cart
agit comme un tiroir latéral (via un Sheet
), accessible depuis l’icône panier. Il affiche dynamiquement :
le nombre d’articles dans le panier,
le total cumulé des prix,
une liste des produits ajoutés, rendus par
CartItems
.
function Cart() {
const { cart } = useCartStore(useShallow((state) => ({ cart: state.cart })))
let total = 0
if (cart) {
total = cart.reduce((acc, item) => {
return acc + item.price * (item.quantity as number)
}, 0)
}
return (
<Sheet>
<SheetTrigger variant="outline" size="icon" className="relative">
<ShoppingCart className="h-[1.2rem] w-[1.2rem]" />
<span className="absolute -top-2 -right-2 bg-primary text-primary-foreground rounded-full w-5 h-5 text-sm">
{cart?.length}
</span>
</SheetTrigger>
<SheetContent>
<SheetHeader className="p-1 space-y-1">
<SheetTitle className="font-bold text-2xl">Shopping Cart</SheetTitle>
<span className="font-semibold text-lg">
Total: {cart?.length && total.toFixed(2)}$
</span>
</SheetHeader>
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 my-4 mb-8">
{cart?.map((item) => (
<CartItems key={item.id} item={item} />
))}
{!cart?.length && (
<span className="text-center font-semibold text-lg">
No items in cart
</span>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
)
}
Je récupère uniquement le morceau de state dont on a besoin (cart
)
Par défaut, Zustand déclenche un re-render dès qu’une partie du store change, même si ce n’est pas la donnée que le composant utilise. Avec useShallow
, je m’assure que le composant ne se re-render que si la clé cart
change réellement, grâce à une comparaison peu profonde. C’est une bonne pratique quand on sélectionne plusieurs morceaux du state ou qu’on veut éviter des re-renders inutiles dans des composants qui dépendent uniquement d’un bout du store.
Doc officielle : useShallow
🧾 CartItems
– Le détail des articles ajoutés
Enfin, CartItems
permet de gérer les interactions sur chaque produit dans le panier :
Incrémenter ou décrémenter la quantité,
Supprimer complètement un produit.
Chaque action déclenche une méthode du store (incrementQuantity
, decrementQuantity
, removeFromCart
), et l’UI se met automatiquement à jour, sans lifting de state fastidieux ni boilerplate.
function CartItems({ item }: cartItemProps) {
const removeItem = useCartStore((state) => state.removeFromCart)
const increaseQuantity = useCartStore((state) => state.incrementQuantity)
const decreaseQuantity = useCartStore((state) => state.decrementQuantity)
return (
<Card className="p-4 flex flex-col gap-1">
<div className="flex items-start gap-2">
<img
src={item.image}
alt={item.title}
width={100}
height={100}
className="object-contain h-16 w-16"
/>
<h3 className="text-xl font-semibold flex flex-col gap-1">
<span>{item.title}</span>
<span className="text-lg font-medium">${item.price}</span>
</h3>
</div>
<div className="flex justify-between items-center text-md font-medium">
<span className="flex items-center gap-1">
Quantity:
<Button
className="w-5 h-5 p-0"
onClick={() => decreaseQuantity(item)}
disabled={item.quantity === 1}
>
<Minus className="w-3 h-3" />
</Button>
{item.quantity}
<Button
className="w-5 h-5 p-0"
onClick={() => increaseQuantity(item)}
>
<Plus className="w-3 h-3" />
</Button>
</span>
<Button onClick={() => removeItem(item)}>
<Trash className="h-5 w-5" />
</Button>
</div>
</Card>
)
}
Résultat : une architecture claire, performante et maintenable
Avec seulement quelques dizaines de lignes de code, nous avons mis en place :
un store centralisé et persistant,
une logique métier claire,
une réactivité optimale dans les composants,
une solution bien plus simple et fluide qu’avec Redux ou Context.
Et voici notre panier :
Zustand permet ainsi d’adopter les avantages d’un store bien conçu, sans s’encombrer de la complexité historique de Redux. Il favorise une architecture modulaire, où la logique métier est centralisée et facilement testable, tout en gardant une interface de consommation ultra-simple.
Conclusion
Les stores ne sont pas une simple tendance : ils répondent à un besoin essentiel lorsqu’une application prend de l’ampleur — structurer la gestion de l’état de manière claire et cohérente. Cela dit, il n’est pas toujours nécessaire d’adopter une solution complexe pour en récolter les avantages.
Zustand prouve qu’on peut allier simplicité, performances et maintenabilité dans une approche minimaliste et moderne. Si tu cherches une alternative à Redux, ou simplement une façon plus propre de structurer ton état dans une app React, Zustand mérite clairement une place dans ta boîte à outils.
Pour aller plus loin
Zustand est un excellent point d’entrée pour comprendre les concepts de state global sans s’encombrer d’outils trop complexes. Mais si tu veux aller plus loin, voici quelques pistes à explorer :
Explorer la persistance d’état plus en profondeur (localStorage, IndexedDB, synchronisation entre onglets, etc.).
Gérer des états plus complexes : relations entre entités, données asynchrones, pagination ou filtrage.
Connecter ton store à un backend réel avec TanStack Query pour une approche plus modulaire.
Ajouter des tests unitaires à ton store pour fiabiliser ton code (Zustand s’y prête bien avec des fonctions pures).
Explorer les middlewares Zustand :
zustand/devtools pour le debug avec Redux DevTools,
subscribeWithSelector pour mieux contrôler les rerenders,
Créer ton propre middleware pour du logging, du cache ou de la surveillance d’event.
Subscribe to my newsletter
Read articles from Léo Couffinhal directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
