Zustand State Management : modèles pour les applications complexes
Les primitives d'état intégrées de React (useState, useReducer, useContext) fonctionnent bien pour l'état de l'interface utilisateur locale et partagée, mais elles rencontrent des frictions à grande échelle : les fournisseurs de contexte restituent l'intégralité de l'arborescence à chaque changement d'état, useReducer nécessite un passe-partout d'action détaillé et le forage d'accessoires devient ingérable. Redux résout ce problème, mais au prix d'un passe-partout et d'une surcharge mentale importants.
Zustand occupe un juste milieu : 2,9 Ko, pas de fournisseur, pas de passe-partout, TypeScript d'abord et compatible avec React DevTools. Il excelle dans l'état de l'application côté client (caddies, état de la barre latérale, données de formulaire en plusieurs étapes, préférences de l'interface utilisateur) tandis que TanStack Query gère l'état du serveur. Ce guide couvre les modèles Zustand v5 pour les applications de production.
Points clés à retenir
- Zustand est réservé aux clients ; utilisez TanStack Query pour l'état du serveur - ne chevauchez pas leurs responsabilités
- Aucun wrapper de fournisseur nécessaire : le magasin est un singleton au niveau du module, accessible n'importe où
- Utiliser le modèle de tranches pour les grands magasins : composez plusieurs créateurs de tranches en un seul appel
create()- Le middleware
immerpermet une syntaxe de mutation directe à l'intérieur des actions (pas d'opérateurs de propagation)- Middleware
persistaveclocalStorageousessionStoragepour l'état inter-session- Le middleware
devtoolss'intègre à l'extension Redux DevTools pour le débogage des voyages dans le temps- Les sélecteurs avec
useShallowempêchent les rendus inutiles à partir d'objets peu profonds- Ne stockez jamais les données du serveur dans Zustand — l'invalidation devient votre problème
Magasin de base
// src/lib/stores/ui.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface UiState {
// State
sidebarOpen: boolean;
theme: 'light' | 'dark' | 'system';
commandPaletteOpen: boolean;
activeModal: string | null;
// Actions
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setTheme: (theme: UiState['theme']) => void;
openModal: (modalId: string) => void;
closeModal: () => void;
toggleCommandPalette: () => void;
}
export const useUiStore = create<UiState>()(
devtools(
(set) => ({
sidebarOpen: true,
theme: 'system',
commandPaletteOpen: false,
activeModal: null,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar'),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'setSidebarOpen'),
setTheme: (theme) =>
set({ theme }, false, 'setTheme'),
openModal: (modalId) =>
set({ activeModal: modalId }, false, 'openModal'),
closeModal: () =>
set({ activeModal: null }, false, 'closeModal'),
toggleCommandPalette: () =>
set(
(state) => ({ commandPaletteOpen: !state.commandPaletteOpen }),
false,
'toggleCommandPalette'
),
}),
{ name: 'ui-store' }
)
);
// Usage in components — select only what you need
function Sidebar() {
const isOpen = useUiStore((state) => state.sidebarOpen);
const toggle = useUiStore((state) => state.toggleSidebar);
return (
<aside className={cn('sidebar', isOpen && 'sidebar--open')}>
<button onClick={toggle}>Toggle</button>
</aside>
);
}
Cart Store avec persistance
// src/lib/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
licenseType: 'one-time' | 'subscription';
}
interface CartState {
items: CartItem[];
couponCode: string | null;
couponDiscount: number;
// Computed (derived state — use selectors, not stored values)
// subtotal, total, itemCount — computed in selectors
// Actions
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
applyCoupon: (code: string, discount: number) => void;
removeCoupon: () => void;
clearCart: () => void;
}
export const useCart = create<CartState>()(
devtools(
persist(
immer((set) => ({
items: [],
couponCode: null,
couponDiscount: 0,
addItem: (newItem) =>
set((state) => {
const existing = state.items.find((i) => i.productId === newItem.productId);
if (existing) {
existing.quantity += 1; // Immer allows direct mutation
} else {
state.items.push({ ...newItem, quantity: 1 });
}
}),
removeItem: (productId) =>
set((state) => {
state.items = state.items.filter((i) => i.productId !== productId);
}),
updateQuantity: (productId, quantity) =>
set((state) => {
if (quantity <= 0) {
state.items = state.items.filter((i) => i.productId !== productId);
} else {
const item = state.items.find((i) => i.productId === productId);
if (item) item.quantity = quantity;
}
}),
applyCoupon: (code, discount) =>
set((state) => {
state.couponCode = code;
state.couponDiscount = discount;
}),
removeCoupon: () =>
set((state) => {
state.couponCode = null;
state.couponDiscount = 0;
}),
clearCart: () =>
set((state) => {
state.items = [];
state.couponCode = null;
state.couponDiscount = 0;
}),
})),
{
name: 'ecosire-cart',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
// Only persist items and coupon, not derived state
items: state.items,
couponCode: state.couponCode,
couponDiscount: state.couponDiscount,
}),
}
),
{ name: 'cart-store' }
)
);
// Selectors — computed from state, not stored
export const selectSubtotal = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectTotal = (state: CartState) => {
const subtotal = selectSubtotal(state);
return subtotal * (1 - state.couponDiscount);
};
export const selectItemCount = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
Modèle de tranche pour les grands magasins
Pour les magasins complexes, divisez en tranches et composez :
// src/lib/stores/slices/filters.slice.ts
import type { StateCreator } from 'zustand';
export interface FiltersSlice {
search: string;
status: string;
page: number;
sortField: string;
sortDirection: 'asc' | 'desc';
setSearch: (search: string) => void;
setStatus: (status: string) => void;
setPage: (page: number) => void;
setSort: (field: string, direction: 'asc' | 'desc') => void;
resetFilters: () => void;
}
const initialFilters = {
search: '',
status: 'all',
page: 1,
sortField: 'createdAt',
sortDirection: 'desc' as const,
};
export const createFiltersSlice: StateCreator<FiltersSlice> = (set) => ({
...initialFilters,
setSearch: (search) => set({ search, page: 1 }), // Reset page on search
setStatus: (status) => set({ status, page: 1 }),
setPage: (page) => set({ page }),
setSort: (field, direction) => set({ sortField: field, sortDirection: direction }),
resetFilters: () => set(initialFilters),
});
// src/lib/stores/slices/selection.slice.ts
import type { StateCreator } from 'zustand';
export interface SelectionSlice {
selectedIds: Set<string>;
selectAll: boolean;
toggleSelection: (id: string) => void;
selectAllItems: (ids: string[]) => void;
clearSelection: () => void;
}
export const createSelectionSlice: StateCreator<SelectionSlice> = (set) => ({
selectedIds: new Set(),
selectAll: false,
toggleSelection: (id) =>
set((state) => {
const next = new Set(state.selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedIds: next, selectAll: false };
}),
selectAllItems: (ids) =>
set({ selectedIds: new Set(ids), selectAll: true }),
clearSelection: () =>
set({ selectedIds: new Set(), selectAll: false }),
});
// src/lib/stores/contacts-table.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createFiltersSlice, type FiltersSlice } from './slices/filters.slice';
import { createSelectionSlice, type SelectionSlice } from './slices/selection.slice';
type ContactsTableStore = FiltersSlice & SelectionSlice;
export const useContactsTableStore = create<ContactsTableStore>()(
devtools(
(...args) => ({
...createFiltersSlice(...args),
...createSelectionSlice(...args),
}),
{ name: 'contacts-table-store' }
)
);
Prévenir les rendus inutiles
Zustand restitue le composant lorsque la valeur sélectionnée change (par référence pour les objets).
// PROBLEM: new object created on every render → infinite re-renders
const { search, page } = useContactsTableStore((state) => ({
search: state.search,
page: state.page,
}));
// SOLUTION 1: Select primitives individually
const search = useContactsTableStore((state) => state.search);
const page = useContactsTableStore((state) => state.page);
// SOLUTION 2: Use useShallow for object selections
import { useShallow } from 'zustand/react/shallow';
const { search, page } = useContactsTableStore(
useShallow((state) => ({ search: state.search, page: state.page }))
);
// SOLUTION 3: Select an action (actions never change reference)
const setSearch = useContactsTableStore((state) => state.setSearch);
Zustand + Requête TanStack : Effacer la séparation
La discipline clé : Zustand pour l'état du client, TanStack Query pour l'état du serveur - ne se chevauchent jamais.
// WRONG: storing server data in Zustand
const useContactsStore = create(() => ({
contacts: [], // Server data in Zustand — invalidation nightmare
fetchContacts: async () => { /* fetch and set */ },
}));
// CORRECT: TanStack Query for server data, Zustand for UI state
// TanStack Query
const { data: contacts } = useContacts({ page, limit, search });
// Zustand — only UI-specific state
const { search, page, setSearch, setPage } = useContactsTableStore();
La clé de requête réagit aux changements d'état de Zustand, donc le filtrage/tri fonctionne simplement :
function ContactsPage() {
// UI state from Zustand
const search = useContactsTableStore((s) => s.search);
const page = useContactsTableStore((s) => s.page);
const status = useContactsTableStore((s) => s.status);
// Server state from TanStack Query, driven by Zustand state
const { data, isLoading } = useContacts({ search, page, status, limit: 20 });
return <ContactsTable data={data?.data ?? []} loading={isLoading} />;
}
Magasin de formulaires en plusieurs étapes
// src/lib/stores/onboarding.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface OnboardingData {
company: { name: string; size: string; industry: string };
contact: { name: string; email: string; phone: string };
requirements: { erp: string; modules: string[]; budget: string };
}
interface OnboardingStore {
currentStep: number;
completed: Set<number>;
data: Partial<OnboardingData>;
setStep: (step: number) => void;
updateStepData: <K extends keyof OnboardingData>(
step: K,
data: OnboardingData[K]
) => void;
markStepComplete: (step: number) => void;
reset: () => void;
}
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set) => ({
currentStep: 0,
completed: new Set(),
data: {},
setStep: (step) => set({ currentStep: step }),
updateStepData: (key, stepData) =>
set((state) => ({
data: { ...state.data, [key]: stepData },
})),
markStepComplete: (step) =>
set((state) => ({
completed: new Set([...state.completed, step]),
})),
reset: () => set({ currentStep: 0, completed: new Set(), data: {} }),
}),
{
name: 'onboarding-progress',
storage: createJSONStorage(() => sessionStorage),
// Serialize Set properly
partialize: (state) => ({
...state,
completed: Array.from(state.completed),
}),
merge: (persisted: any, current) => ({
...current,
...persisted,
completed: new Set(persisted.completed ?? []),
}),
}
)
);
Test des magasins Zustand
// src/lib/stores/__tests__/cart.store.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCart, selectSubtotal, selectTotal } from '../cart';
describe('Cart Store', () => {
beforeEach(() => {
// Reset store state between tests
useCart.setState({
items: [],
couponCode: null,
couponDiscount: 0,
});
});
it('adds items to the cart', () => {
const { addItem } = useCart.getState();
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
const { items } = useCart.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(1);
});
it('increments quantity when same product added again', () => {
const { addItem } = useCart.getState();
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
const { items } = useCart.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(2);
});
it('calculates subtotal correctly', () => {
useCart.setState({
items: [
{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 2, licenseType: 'one-time' },
{ id: '2', productId: 'p2', name: 'B', price: 50, quantity: 1, licenseType: 'one-time' },
],
});
expect(selectSubtotal(useCart.getState())).toBe(250);
});
it('applies coupon discount', () => {
useCart.setState({
items: [{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 1, licenseType: 'one-time' }],
couponCode: 'SAVE20',
couponDiscount: 0.2,
});
expect(selectTotal(useCart.getState())).toBe(80);
});
});
Questions fréquemment posées
Ai-je toujours besoin de l'API Context avec Zustand ?
Rarement. Le singleton au niveau du module de Zustand remplace la plupart des cas d'utilisation de l'API Context. Le principal cas d'utilisation restant de Context est l'injection de dépendances : transmettre une instance de magasin spécifique à une sous-arborescence (utile pour les tests ou lorsque vous avez besoin de plusieurs instances de magasin indépendantes). Pour cela, Zustand fournit createStore() + useStore() qui fonctionnent avec un contexte React pour l'instance de magasin.
Comment Zustand se compare-t-il à Redux Toolkit ?
Redux Toolkit a plus de structure (tranches, réducteurs, actions) et une meilleure intégration de DevTools pour le débogage complexe de voyages dans le temps. Zustand a moins de passe-partout, un bundle plus petit (2,9 Ko contre ~ 30 Ko pour Redux + RTK) et ne nécessite pas de fournisseur. Pour la plupart des applications, Zustand est plus simple et suffisant. Choisissez Redux Toolkit si vous disposez d'une grande équipe qui bénéficie de modèles appliqués ou si vous avez besoin de fonctionnalités avancées de DevTools.
Comment utiliser Zustand avec le routeur d'application Next.js SSR ?
Les magasins Zustand sont des singletons au niveau du module — sans danger pour les composants clients mais ne peuvent pas être utilisés dans les composants serveur (pas d'API de navigateur, pas de fenêtre). Initialisez les magasins dans les composants 'use client' uniquement. Pour l'hydratation SSR des données initiales, utilisez le HydrationBoundary de TanStack Query pour les données du serveur. Pour l’état client uniquement (panier, préférences de l’interface utilisateur) qui doit persister tout au long de la navigation, utilisez le middleware persist avec localStorage.
Qu'est-ce que le middleware immer et quand dois-je l'utiliser ?
Le middleware immer enveloppe la fonction set de Zustand avec Immer, vous permettant d'écrire du code mutant dans des actions (state.items.push(item)) qui est en réalité immuable sous le capot. Utilisez-le lorsque vous avez des mises à jour d'état imbriquées qui sont difficiles à utiliser avec une syntaxe étendue, comme la mise à jour d'une propriété profondément imbriquée ou l'utilisation de tableaux. Cela ajoute environ 6 Ko à votre offre groupée – cela en vaut la peine pour les magasins complexes, inutile pour les magasins simples.
Comment puis-je éviter les fermetures obsolètes dans les actions Zustand ?
Zustand transmet l'état actuel au rappel set, utilisez donc toujours la forme fonctionnelle lors de la lecture de l'état dans une action : set((state) => ({ count: state.count + 1 })) au lieu de capturer state.count à partir de la fermeture. Pour les actions asynchrones qui lisent l'état après un await, appelez getState() au point d'utilisation plutôt que de capturer l'état lors de la fermeture avant l'attente.
Prochaines étapes
L'encombrement minimal de Zustand et son modèle de tranche composable en font l'outil idéal pour l'état client dans les applications React de toute taille. Associé à TanStack Query pour l’état du serveur, vous disposez d’une architecture de gestion d’état propre et évolutive sans surcharge standard.
ECOSIRE crée des applications React avec Zustand + TanStack Query comme pile de gestion d'état standard – au service de panneaux d'administration complexes, de portails clients et de flux de commerce électronique. Découvrez nos services d'ingénierie frontend pour découvrir comment nous concevons des applications React évolutives.
Rédigé par
ECOSIRE Research and Development Team
Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.
Articles connexes
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
API Integration Patterns for OpenClaw Agents
Technical patterns for integrating OpenClaw AI agents with REST APIs, GraphQL, webhooks, and enterprise systems. Covers authentication, error handling, rate limiting, and testing.