Introduction
Zustand est une bibliothèque de gestion d'état pour React qui surpasse Redux en simplicité et performance, avec un API hook-based minimaliste. En 2026, elle domine les apps scalables grâce à ses middlewares natifs (persist, devtools, immer) et son support TypeScript impeccable. Ce tutoriel expert vous guide de la création d'un store basique à des implémentations avancées : actions async avec thunks, sélecteurs optimisés pour éviter les re-renders inutiles, persistance locale, debugging en temps réel et tests unitaires robustes.
Pourquoi Zustand pour les pros ? Il réduit le boilerplate de 90 % par rapport à Redux, gère des états complexes comme un arbre d'objets imbriqués sans immercer dans la mutabilité, et intègre seamlessly avec React Server Components ou TanStack Query. Imaginez un store utilisateur avec authentification async, panier e-commerce persistant et métriques de perf en dev : tout cela en < 100 lignes. À la fin, vous maîtriserez les pièges de performance et scalerez vos apps React sans sueur. (128 mots)
Prérequis
- React 18+ et TypeScript 5.6+
- Vite ou Next.js pour le bundler
- Connaissances avancées en hooks React (useEffect, useCallback)
- Node.js 20+ et npm/yarn/pnpm
- Outils de test : Vitest ou Jest avec @testing-library/react
Installation de Zustand et initialisation
npm create vite@latest mon-app-zustand -- --template react-ts
cd mon-app-zustand
npm install
npm install zustand @zustand-devtools/zustand-devtools immer
npm install -D @types/node
npm run devCette commande crée un projet Vite React+TS, installe Zustand avec ses middlewares essentiels (devtools pour debugging, immer pour mutabilité sûre). Lancez npm run dev pour vérifier. Évitez les versions obsolètes : Zustand 5+ est requis pour les APIs 2026.
Premier store basique
Commençons par un store simple gérant un compteur et une liste de tâches. Zustand utilise create pour définir l'état initial et les actions en une fonction. Pas de provider requis : les hooks fonctionnent partout.
Store basique avec actions sync
import { create } from 'zustand';
import { devtools } from '@zustand-devtools/zustand-devtools';
type Task = { id: number; text: string; done: boolean };
type TaskStore = {
count: number;
tasks: Task[];
addTask: (text: string) => void;
toggleTask: (id: number) => void;
increment: () => void;
};
export const useTaskStore = create<TaskStore>()(
devtools((set, get) => ({
count: 0,
tasks: [],
addTask: (text) => {
const newTask: Task = { id: Date.now(), text, done: false };
set({ tasks: [...get().tasks, newTask], count: get().count + 1 });
},
toggleTask: (id) => {
set({
tasks: get().tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
),
});
},
increment: () => set((state) => ({ count: state.count + 1 })),
})),
);Ce store définit un état avec compteur et tâches, des actions immutables via set et get. devtools active le panneau Redux DevTools pour inspecter l'état en live. Utilisez set((state) => ...) pour des updates dérivés : c'est plus performant que des closures externes.
Utilisation dans un composant React
Intégrez le store via useTaskStore. Les sélecteurs évitent les re-renders : useTaskStore((state) => state.count) ne re-render que si count change.
Composant consommateur avec sélecteurs
import { useTaskStore } from './store/taskStore';
function App() {
const count = useTaskStore((state) => state.count);
const tasks = useTaskStore((state) => state.tasks);
const addTask = useTaskStore((state) => state.addTask);
const toggleTask = useTaskStore((state) => state.toggleTask);
const increment = useTaskStore((state) => state.increment);
return (
<div>
<h1>Compteur: {count}</h1>
<button onClick={increment}>+1</button>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleTask(task.id)}
/>
{task.text}
</li>
))}
</ul>
<input placeholder="Nouvelle tâche" onKeyDown={(e) => {
if (e.key === 'Enter') {
addTask((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = '';
}
}} />
</div>
);
}
export default App;
import React from 'react';Chaque selector est un hook indépendant : React optimise les re-renders via shallow equality interne de Zustand. Ajoutez des useCallback si besoin pour les callbacks enfants. Ce composant est fully fonctionnel et scalable.
Actions asynchrones avec thunks
Pour les APIs, utilisez des thunks : actions qui retournent des promesses. Zustand gère nativement l'async sans middleware externe comme Redux Toolkit.
Store avec thunks async et loading states
import { create } from 'zustand';
import { devtools } from '@zustand-devtools/zustand-devtools';
type User = { id: number; name: string; email: string };
type UserStore = {
users: User[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
createUser: (user: Omit<User, 'id'>) => Promise<void>;
};
const fakeApi = async (delay = 1000): Promise<User[]> => {
await new Promise((r) => setTimeout(r, delay));
return [
{ id: 1, name: 'Alice', email: 'alice@test.com' },
{ id: 2, name: 'Bob', email: 'bob@test.com' },
];
};
export const useUserStore = create<UserStore>()(
devtools((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const users = await fakeApi();
set({ users, loading: false });
} catch (e) {
set({ error: 'Erreur fetch', loading: false });
}
},
createUser: async (newUser) => {
set({ loading: true });
try {
await fakeApi(500);
const users = get().users;
const id = Math.max(...users.map(u => u.id), 0) + 1;
set({ users: [...users, { ...newUser, id }], loading: false });
} catch (e) {
set({ error: 'Erreur création', loading: false });
}
},
})),
);Les thunks async utilisent async/await directement dans les actions. Gérez loading/error globalement pour UX fluide. fakeApi simule une API réelle : remplacez par fetch. Évitez les race conditions en lisant get() avant async.
Middlewares avancés : persist et immer
Ajoutez persistance locale (localStorage) et mutabilité sûre avec immer. En prod, persist synchronise l'état au storage sans effort.
Store avec persist, immer et devtools
import { create } from 'zustand';
import { devtools, persist } from '@zustand-devtools/zustand-devtools';
import { immer } from 'zustand/middleware/immer';
type Item = { id: string; qty: number; price: number };
type CartStore = {
items: Record<string, Item>;
total: number;
addItem: (id: string, price: number) => void;
updateQty: (id: string, qty: number) => void;
removeItem: (id: string) => void;
};
export const useCartStore = create<CartStore>()(
devtools(
persist(
immer((set, get) => ({
items: {},
total: 0,
addItem: (id, price) => {
const current = get().items[id];
set((state) => {
state.items[id] = current ? { ...current, qty: current.qty + 1 } : { id, qty: 1, price };
state.total = Object.values(state.items).reduce((sum, item) => sum + item.qty * item.price, 0);
});
},
updateQty: (id, qty) => {
set((state) => {
if (qty === 0) {
delete state.items[id];
} else {
state.items[id] = { ...state.items[id], qty };
}
state.total = Object.values(state.items).reduce((sum, item) => sum + item.qty * item.price, 0);
});
},
removeItem: (id) => {
set((state) => {
delete state.items[id];
state.total = Object.values(state.items).reduce((sum, item) => sum + item.qty * item.price, 0);
});
},
})),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }),
},
),
{ name: 'CartStore' },
),
);Stack de middlewares : immer pour muter directement (state.items[id] = ...), persist pour localStorage (partialize ignore total), devtools pour traces. Ordre critique : immer avant persist. En SSR, utilisez hydrate pour éviter hydration mismatch.
Sélecteurs avancés et performance
Pour les gros états, utilisez shallow equality et sélecteurs dérivés. Zustand shallow-compare par défaut, mais createWithEqualityFn pour custom.
Sélecteurs optimisés avec shallow
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { devtools, persist } from '@zustand-devtools/zustand-devtools';
type Metrics = { users: number; revenue: number; events: string[] };
type AnalyticsStore = {
metrics: Metrics;
history: Metrics[];
updateMetrics: (newMetrics: Partial<Metrics>) => void;
};
export const useAnalyticsStore = create<AnalyticsStore>()(
devtools((set) => ({
metrics: { users: 0, revenue: 0, events: [] },
history: [],
updateMetrics: (newMetrics) => {
set((state) => {
const updated = { ...state.metrics, ...newMetrics };
return {
metrics: updated,
history: [...state.history.slice(-9), updated],
};
});
},
})),
);
// Usage optimisé dans composants:
// const { users, revenue } = useAnalyticsStore(
// (state) => ({ users: state.metrics.users, revenue: state.metrics.revenue }),
// shallow
// );
// Évite re-render si events change seul.Importez shallow pour comparer objets shallow : idéal pour tuples dérivés. history.slice(-9) limite la taille. Dans composants, passez shallow en 2e arg du selector pour zero re-renders inutiles sur gros états.
Tests unitaires des stores
Testez stores isolément avec Vitest. Mockez middlewares et assert état/actions.
Tests unitaires complets
import { renderHook, act } from '@testing-library/react';
import { useTaskStore } from './taskStore';
describe('TaskStore', () => {
it('should add and toggle tasks', () => {
const { result } = renderHook(() => useTaskStore());
act(() => {
result.current.addTask('Test task');
});
expect(result.current.tasks).toHaveLength(1);
expect(result.current.tasks[0].text).toBe('Test task');
act(() => {
result.current.toggleTask(result.current.tasks[0].id);
});
expect(result.current.tasks[0].done).toBe(true);
expect(result.current.count).toBe(1);
});
it('should increment count', () => {
const { result } = renderHook(() => useTaskStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});renderHook + act simule React sans DOM. Testez purement l'API store. Pour async/thunks, utilisez waitFor. Couvrez 100 % : actions, edge cases (empty list, errors).
Bonnes pratiques
- Toujours typer strictement : utilisez
StateCreatorgénérique pour IntelliSense parfait. - Séparez concerns : un store par domaine (user, cart, UI).
- Middleware stack minimal : immer+persist+devtools seulement ; désactivez persist en test.
- Selectors first : dérivez computed state au lieu de recalculs en composant.
- SSR safe : utilisez
useShallowetnoSSRpour middlewares en Next.js.
Erreurs courantes à éviter
- Muter l'état directement sans immer : crash en prod (useState-like erreur).
- Oublier shallow sur objets : re-renders infinis sur listes.
- Persister tout : partialize pour éviter fuites mémoire (ne stockez pas transients comme loading).
- Race conditions async : toujours
get()avant await, pas closures capturées.
Pour aller plus loin
- Docs officielles : Zustand GitHub
- Middleware avancé : Zustand Router
- Intégration TanStack : pour queries + mutations
- Découvrez nos formations React avancées Learni pour scaler vos apps stateful.