Introduction
En 2026, Zustand reste le choix privilégié des développeurs React pour un gestionnaire d'état minimaliste et ultra-performant, surpassant Redux en simplicité tout en offrant une scalabilité experte. Contrairement à des solutions verbeuses, Zustand utilise un hook useStore unique, évitant les re-renders inutiles via des sélecteurs fins. Ce tutoriel expert explore les configurations avancées : persistance locale, intégration Immer pour mutabilité sûre, middlewares devtools pour debugging, actions asynchrones avec thunks, et architectures multi-stores. Idéal pour des apps d'entreprise complexes comme dashboards analytiques ou e-commerces réactifs. Vous apprendrez à structurer des stores modulaires, optimiser les performances (jusqu'à 90% de re-renders en moins), et gérer des états globaux partagés sans boilerplate. À la fin, vos apps React seront prêtes pour la production à grande échelle. (142 mots)
Prérequis
- React 18+ et TypeScript 5+
- Connaissances avancées des hooks React (useEffect, useMemo, useCallback)
- Vite ou Next.js pour l'environnement de dev
- Node.js 20+ et npm/yarn/pnpm
Installation de Zustand et middlewares
npm create vite@latest mon-app-zustand -- --template react-ts
cd mon-app-zustand
npm install
npm install zustand
npm install @zustand-devtools/devtools zustand/middleware
npm install immer
npm run devCette commande initialise un projet Vite React-TypeScript, installe Zustand core, les middlewares devtools pour debugging temps réel, et Immer pour mutabilité immutable. Lancez npm run dev pour tester immédiatement. Évitez pnpm si conflits de lockfiles.
Premier store basique avec sélecteurs
Commençons par les fondations : un store simple gérant un compteur avec actions et sélecteurs. Les sélecteurs évitent les re-renders complets, comme un 'projecteur focalisé' sur une partie de l'état. Chaque composant ne se re-render que si ses données changent.
Créer un store compteur basique
import { create } from 'zustand';
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));Ce store définit un état count et trois actions immutables via set. Utilisez des updates fonctionnels (state) => ({ ... }) pour des calculs dérivés sûrs. Piège : évitez set(state => state) qui mute directement.
Utilisation dans un composant React
import { useCounterStore } from './stores/counterStore';
function App() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div style={{ padding: '20px', fontFamily: 'Arial' }}>
<h1>Compteur: {count}</h1>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
);
}
export default App;Destructurez sélectivement pour minimiser les re-renders. Chaque bouton trigger une action précise. Testez : seul le h1 se met à jour. Avantage expert : pas de Provider, store global instantané.
Sélecteurs dérivés pour optimisation
Pour les apps expertes, les sélecteurs dérivés calculent des valeurs en temps réel sans stocker tout. Analogy : comme un getter virtuel qui 'compte les moutons' sans les énumérer à chaque fois.
Store avec sélecteurs dérivés
import { create } from 'zustand';
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
doubleCount: number;
};
export const useCounterStore = create<CounterState>((set, get) => ({
count: 0,
doubleCount: 0,
increment: () => {
const newCount = get().count + 1;
set({ count: newCount, doubleCount: newCount * 2 });
},
decrement: () => {
const newCount = get().count - 1;
set({ count: newCount, doubleCount: newCount * 2 });
},
}));Utilisez get() pour lire l'état actuel dans les actions. doubleCount est dérivé et mis à jour atomiquement. Piège : synchronisez toujours les dérivés pour cohérence.
Persistance avec middleware persist
Pour survivre aux refreshs, intégrez persist : stocke en localStorage par défaut, configurable pour IndexedDB ou sessionStorage.
Store persistant avec clé custom
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type PersistState = {
count: number;
increment: () => void;
};
export const usePersistStore = create<PersistState>()(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'counter-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ count: state.count }),
}
)
);Le middleware persist sérialise l'état. partialize ne persiste que count pour sécurité. createJSONStorage customise le storage. Rechargez la page : état conservé.
Devtools pour debugging expert
Les devtools Zustand s'intègrent à Redux DevTools : time-travel, inspection actions/état.
Middleware devtools intégré
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type DevtoolsState = {
todos: string[];
addTodo: (todo: string) => void;
removeTodo: (index: number) => void;
};
export const useDevtoolsStore = create<DevtoolsState>()(
devtools(
(set) => ({
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
removeTodo: (index) => set((state) => ({ todos: state.todos.filter((_, i) => i !== index) })),
}),
{ name: 'todos-devtools' }
)
);devtools wrappe le store avec un nom pour identification. Ouvrez Redux DevTools : tracez actions. Piège : désactivez en prod avec if (process.env.NODE_ENV === 'development').
Mutabilité sûre avec Immer
Immer permet de 'muter' l'état draft sans immutabilité manuelle, idéal pour objets/nesteds complexes.
Store avec Immer pour nested state
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
type UserState = {
users: { id: number; name: string; tasks: string[] }[];
addUser: (name: string) => void;
addTask: (userId: number, task: string) => void;
};
export const useImmerStore = create<UserState>()(
immer((set) => ({
users: [],
addUser: (name) => {
set((state) => {
state.users.push({ id: Date.now(), name, tasks: [] });
});
},
addTask: (userId, task) => {
set((state) => {
const user = state.users.find((u) => u.id === userId);
if (user) user.tasks.push(task);
});
},
}))
);immer autorise mutations draft : state.users.push(). Immer produit un état immutable final. Parfait pour graphs d'état profonds sans spread operators verbeux.
Actions asynchrones avec thunks
Pour APIs/fetch, implémentez thunks : actions async qui dispatchent loading/error/success.
Store async avec fetch API
import { create } from 'zustand';
type AsyncState = {
data: string[];
loading: boolean;
error: string | null;
fetchData: () => Promise<void>;
};
export const useAsyncStore = create<AsyncState>((set, get) => ({
data: [],
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
const todos = await res.json();
set({
data: todos.map((todo: any) => todo.title),
loading: false
});
} catch (err) {
set({ loading: false, error: 'Erreur fetch' });
}
},
}));Actions async utilisent await et set multiple fois pour états intermédiaires. get() pour cancel tokens avancés. Gérez erreurs globalement pour UX robuste.
Architecture multi-stores (slices)
Pour scalabilité, combinez slices : combinez stores modulaires sans couplage.
Multi-stores avec combine
import { create } from 'zustand';
import { combine } from 'zustand/middleware';
type CounterSlice = {
count: number;
increment: () => void;
};
type TodoSlice = {
todos: string[];
addTodo: (todo: string) => void;
};
export const useCombinedStore = create(
combine(
{ count: 0 as number, todos: [] as string[] },
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
})
)
);combine fusionne slices logiques. Facilite refactoring : extrayez slices en fichiers séparés. Piège : évitez circular deps entre slices.
Bonnes pratiques
- Séparez concerns : un slice par feature (users, cart, UI).
- Typez tout : utilisez
StateCreatorpour middlewares chainés. - Optimisez sélecteurs : shallow equality avec
shallowde Zustand pour arrays/objects. - Lazy hydration : pour persistance SSR avec Next.js, hydrate on-mount.
- Middleware chaining :
devtools(persist(immer(store)))pour stack complet.
Erreurs courantes à éviter
- Mutations directes sans Immer : provoque états inconsistants et re-renders infinis.
- Sélecteurs non-mémorisés : destructurez large, causant perf drops (utilisez
store((state) => state.slice)). - Persistance sensible : ne stockez jamais tokens/secrets, utilisez
partialize({}). - Async sans loading : oubliez états loading/error, dégrade UX (toujours try/catch).
Pour aller plus loin
Approfondissez avec la doc officielle Zustand. Intégrez Jotai pour granularité atomique ou Valtio pour proxies. Découvrez nos formations React avancées Learni pour state management en prod. Exemples GitHub : recherchez 'zustand-vanilla-extract' pour styling intégré.