La gestion d'état en React a une longue histoire de surengineering. Redux a apporté de la structure mais aussi du boilerplate, des action types, des reducers, des dispatchers, et tout un modèle mental à assimiler avant d'écrire la moindre ligne de logique métier. L'API Context a résolu le problème du prop-drilling mais re-rend tout ce qui la consomme, ce qui en fait un mauvais choix pour de l'état qui change fréquemment. Zustand se place au juste milieu : un store qu'on crée en cinq lignes, avec des re-renders chirurgicaux, de l'inférence TypeScript native, et zéro opinion sur la façon dont vous structurez votre application.
Un store dans sa forme la plus simple
Un store Zustand est un hook. On définit son état et ses actions dans un seul appel à create, et n'importe quel composant peut le consommer directement sans envelopper l'arbre dans un provider.
import { create } from 'zustand';
interface CounterStore {
count: number;
increment: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}Pas de provider, pas de context, pas de dispatch. Le composant s'abonne exactement à la tranche d'état dont il a besoin, et ne se re-rend que quand cette tranche change. Un composant voisin qui lit reset mais pas count ne sera pas re-rendu quand le compteur change.
Sélecteurs et contrôle des re-renders
La clé de la performance de Zustand, ce sont les sélecteurs. À chaque appel du hook avec une fonction sélecteur, Zustand compare le résultat précédent et suivant par égalité stricte. Si rien n'a changé, le composant reste en place.
// Ne se re-rend que quand `name` change, pas quand d'autres champs du store sont mis à jour
const name = useUserStore((state) => state.name);Pour les valeurs dérivées qui retournent de nouvelles références d'objet, utilisez useShallow pour comparer par égalité superficielle.
import { useShallow } from 'zustand/shallow';
const { items, total } = useCartStore(
useShallow((state) => ({ items: state.items, total: state.total }))
);Ce pattern remplace ce qui serait un combo useMemo + useSelector avec Redux. Une seule ligne, pas de hook supplémentaire.
Actions asynchrones
Zustand n'impose aucun middleware pour la logique asynchrone. Les actions sont de simples fonctions, on appelle set quand les données sont prêtes.
interface ProductStore {
products: Product[];
isLoading: boolean;
fetchProducts: () => Promise<void>;
}
export const useProductStore = create<ProductStore>((set) => ({
products: [],
isLoading: false,
fetchProducts: async () => {
set({ isLoading: true });
const products = await api.getProducts();
set({ products, isLoading: false });
},
}));Pas de thunks, pas de sagas, pas de package supplémentaire. La fonction asynchrone vit dans le store à côté des actions synchrones, et TypeScript infère tout.
Des middlewares qui se composent
Le système de middleware de Zustand repose sur la composition fonctionnelle. Chaque middleware enveloppe le créateur de store, et on les empile selon les besoins.
Persistance dans le localStorage
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SettingsStore {
theme: 'light' | 'dark';
locale: string;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
locale: 'en',
setTheme: (theme) => set({ theme }),
}),
{ name: 'settings-storage' }
)
);Le store se réhydrate depuis le localStorage au montage. On peut remplacer le moteur de stockage par sessionStorage, IndexedDB, ou n'importe quelle solution asynchrone en fournissant une option storage personnalisée.
Intégration avec les devtools
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useAuthStore = create<AuthStore>()(
devtools(
(set) => ({
user: null,
login: async (credentials) => {
const user = await api.login(credentials);
set({ user }, false, 'auth/login');
},
}),
{ name: 'AuthStore' }
)
);Le troisième argument de set nomme l'action dans Redux DevTools. On obtient le time-travel debugging sans Redux.
Passer à l'échelle avec les slices
Quand le store grossit, on peut le découper en slices qui se composent en un seul store. Chaque slice définit sa propre portion d'état et ses actions.
import { create } from 'zustand';
interface UserSlice {
user: User | null;
setUser: (user: User) => void;
}
interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
}
type AppStore = UserSlice & CartSlice;
const createUserSlice = (set: any): UserSlice => ({
user: null,
setUser: (user) => set({ user }),
});
const createCartSlice = (set: any): CartSlice => ({
items: [],
addItem: (item) => set((state: AppStore) => ({ items: [...state.items, item] })),
});
export const useAppStore = create<AppStore>()((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}));Les composants continuent de choisir exactement ce dont ils ont besoin via les sélecteurs. Le découpage interne est invisible pour les consommateurs. Ce pattern monte en charge sur de grosses applications sans la cérémonie du dossier-par-feature qu'exige Redux.
Quand Zustand est le bon choix
Zustand n'est pas toujours la réponse. Pour de l'état véritablement global et complexe avec des mises à jour profondément imbriquées et de l'undo/redo, Redux Toolkit a encore sa place. Pour l'état serveur (fetching, cache, revalidation), TanStack Query ou SWR sont de meilleurs outils. Zustand brille pour l'état applicatif côté client : toggles d'UI, wizards de formulaire, paniers d'achat, préférences utilisateur, tout état qui vit dans le navigateur et doit être partagé entre composants sans le coût d'un context provider ni la cérémonie de Redux.
Conclusion
Zustand prouve que la gestion d'état n'a pas besoin d'être compliquée. Un store est un hook, les actions sont des fonctions, les sélecteurs contrôlent les re-renders, et les middlewares se composent proprement. Il couvre 90% de ce pour quoi les équipes utilisaient Redux, avec une fraction du code et de la charge cognitive. Si votre prochain projet a besoin d'état client partagé, commencez par là.