Qu’est-ce que Zustand ?
Zustand est un gestionnaire d’état léger et rapide pour React. L’intégralité de son API est facile à mémoriser : vous appelez create avec une fonction qui retourne l’état et les actions, et vous obtenez en retour un hook. Il n’y a ni provider, ni reducer, ni types d’actions, ni dispatch. Vous importez le hook et vous l’utilisez.
Son nom signifie « état » en allemand, et la bibliothèque est fidèle à cette simplicité. Elle pèse environ un kilo-octet, utilise des abonnements basés sur des sélecteurs pour minimiser les re-renders, et fonctionne avec JavaScript et TypeScript. Pour de nombreuses applications React, c’est la solution qui demande le moins de formalités pour partager un état globalement.
Créer un store
Un store est un hook. L’état et les fonctions qui le mettent à jour cohabitent au même endroit.
// store.js
import { create } from "zustand";
export const useStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
double: () => get().count * 2,
}));
set fusionne l’objet retourné dans l’état, et get lit l’état actuel de manière impérative. Il n’y a aucun reducer à écrire ni d’action à dispatcher — seulement des fonctions qui mettent à jour l’état.
Lire l’état avec des sélecteurs
Les composants s’abonnent précisément à la tranche d’état dont ils ont besoin.
// Counter.jsx
import { useStore } from "./store";
export function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
Comme le composant s’abonne uniquement à state.count, les mises à jour de parties non liées du store ne provoquent pas son re-rendu. C’est là la différence majeure de performance avec l’ Context API de React, qui re-rend chaque consommateur dès que la valeur change.
Pour sélectionner plusieurs valeurs, utilisez une comparaison superficielle (shallow comparison) afin que le composant ne se re-rende que lorsqu’une de ces valeurs change réellement :
// shallow.js
import { useShallow } from "zustand/react/shallow";
const { count, increment } = useStore(
useShallow((state) => ({ count: state.count, increment: state.increment })),
);
Mise à jour de l’état
set effectue une fusion par défaut, vous ne spécifiez donc que ce qui a changé. Passez une fonction lorsque la valeur suivante dépend de la précédente.
// updates.js
set({ count: 0 }); // merge
set((state) => ({ count: state.count + 1 })); // derive from previous
set((state) => ({ items: [...state.items, item] })); // arrays
Pour les objets imbriqués, set remplace la clé de premier niveau ; utilisez donc l’opérateur spread sur l’objet existant lorsque vous devez conserver les autres propriétés. Le middleware Immer est disponible si vous préférez une syntaxe de mutation pour les mises à jour profondes.
Actions asynchrones
La logique asynchrone se résume simplement à une fonction async dans le store. Il n’y a aucun middleware à configurer.
// usersStore.js
import { create } from "zustand";
export const useUsers = create((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const res = await fetch("/api/users");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
set({ users: await res.json(), loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
Les indicateurs de chargement (loading) et d’erreur (error) sont stockés juste à côté des données, et les composants ne sélectionnent que les éléments qu’ils doivent afficher.
Slices et middleware
Pour un store volumineux, divisez-le en slices qui seront ensuite combinées en un seul store. Chaque slice est une fonction qui reçoit set et get.
// slices/cart.js
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] }),
});
// store.js
import { create } from "zustand";
import { createCartSlice } from "./slices/cart";
import { createUserSlice } from "./slices/user";
export const useStore = create((...args) => ({
...createCartSlice(...args),
...createUserSlice(...args),
}));
Le middleware se compose comme des fonctions autour du créateur du store. Les plus courants sont :
- persist sauvegarde et réhydrate l’état dans
localStorageou un autre stockage. - devtools connecte le store à l’extension Redux DevTools.
- immer vous permet d’écrire des mises à jour mutables tout en restant immuables.
// persisted.js
import { persist } from "zustand/middleware";
export const useSettings = create(
persist(
(set) => ({
theme: "dark",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings" },
),
);
Quand utiliser Zustand
Zustand est idéal pour l’état global client : thèmes et préférences, paniers d’achat, état de l’UI partagé entre des composants éloignés, brouillons, filtres et état des modales. C’est particulièrement utile lorsque l’utilisation de Context provoquerait trop de re-renders ou quand Redux semble apporter une structure plus complexe que nécessaire.
Ne l’utilisez pas pour l’état serveur. Les données provenant d’une API nécessitent du caching, de la déduplication, du rafraîchissement en arrière-plan et de l’invalidation, et TanStack Query gère bien mieux ces problématiques. La configuration courante consiste à utiliser Zustand pour l’état client et TanStack Query pour l’état serveur.
Bonnes pratiques
- Lisez toujours l’état via un selector.
- Utilisez
useShallowlors de la sélection de plusieurs valeurs. - Gardez les actions dans le store pour que les composants ne connaissent pas la structure de l’état.
- Divisez les stores volumineux en slices.
- Utilisez le persist middleware pour les paramètres qui doivent être conservés après le rechargement de la page.
- Ne stockez pas les données serveur dans le store.
- Stockez, dans la mesure du possible, des données simples et sérialisables.
Erreurs courantes
- Appeler
useStore()sans sélecteur, ce qui provoque un re-rendu à chaque modification. - Retourner un nouvel objet depuis un sélecteur sans effectuer de comparaison superficielle (shallow comparison).
- Utiliser Zustand comme un cache d’état serveur.
- Muter un état imbriqué sans Immer, entraînant la perte de mises à jour.
- Créer un store à l’intérieur d’un composant, ce qui le réinitialise à chaque rendu.
- Ajouter une complexité inutile (ceremony) typique de Redux, alors que Zustand a été conçu pour s’en affranchir.
Et après ?
Zustand représente souvent le juste milieu entre Context et Redux. Comparez-le avec Context pour un partage simple et avec Redux Toolkit pour un état strictement typé et outillé, et déportez vos données serveur vers TanStack Query. Pour Vue, c’est Pinia qui remplit ce même rôle.