# 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1744286005254/c835ae15-ea0b-49fc-871d-f7e71eed4b35.webp align="center")

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](https://niji.tech/stores-step-by-step) de Lionel ZUBER.

Voici maintenant la partie la plus excitante ! Voyez comment **Zustand** fonctionne avec le schéma ci-dessous :

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1744288147556/a743430c-427f-44ca-b004-02eea503d091.webp align="center")

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**](https://www.radix-ui.com/) 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](https://fakestoreapi.com/). 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` :

```typescript
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

```typescript
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 type `Product`),
    
* `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` :

```typescript
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 le `localStorage`)
    
* `set()` permet de modifier le state
    
* `get()` 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.

```typescript
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”

```typescript
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.

```typescript
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.

```typescript
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`.
    

```typescript
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](https://zustand.docs.pmnd.rs/hooks/use-shallow)

#### 🧾 `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.

```typescript
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.
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1744037589418/c8d451c5-2c96-49ea-a039-904d5b205931.png align="center")

Et voici notre panier :

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1744037614789/7056c65c-bc11-4693-a7a2-140c590df641.png align="center")

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](https://zustand.docs.pmnd.rs/middlewares/devtools) pour le debug avec Redux DevTools,
        
    * [subscribeWithSelector](https://zustand.docs.pmnd.rs/middlewares/subscribe-with-selector) pour mieux contrôler les rerenders,
        
    * Créer ton propre middleware pour du logging, du cache ou de la surveillance d’event.
