Gerer l'etat Zustand avec Claude Code
Concevez un store Zustand avec Claude Code: selectors, persist partiel, actions async, tests et prompt de revue.
Commencer par la frontiere de l’etat
Zustand est une bibliotheque legere de gestion d’etat pour React. La gestion d’etat ne consiste pas a mettre toutes les variables dans un objet global; elle consiste a choisir quelles valeurs doivent etre partagees, quelles actions peuvent les modifier et quelles valeurs doivent rester locales au composant. Claude Code peut produire un store rapidement, mais il lui faut une frontiere. Sans elle, texte temporaire de formulaire, donnees d’authentification, reponses API, toasts et panier finissent dans le meme store global.
Ce guide utilise Claude Code comme assistant de conception. Les cas traites sont les filtres d’administration, le panier, l’etat d’UI d’authentification, les modal/toast et les mises a jour optimistes. Les blocs sont copiables: store TypeScript, selectors, persist avec partialize, action asynchrone, tests Vitest et prompt de revue.
La base technique vient de l’introduction officielle de Zustand, de la reference persist middleware et du guide useShallow. Pour la separation avec l’etat serveur, voyez le guide TanStack Query avec Claude Code. Pour un etat plus atomique, comparez avec Jotai atoms.
Ce qui doit entrer dans Zustand
La regle pratique: utilisez Zustand quand plusieurs composants eloignes ont besoin de la meme valeur, quand la logique de mise a jour doit etre testee au meme endroit, ou quand l’URL ne suffit pas a exprimer l’etat d’interface. Ne stockez pas toutes les reponses serveur dans Zustand. Les listes de produits, d’utilisateurs ou de resultats ont besoin de cache, refetch, stale time et gestion d’erreurs; c’est plutot le role d’un outil de server state.
| Cas | Dans Zustand | Hors Zustand | Instruction a Claude Code |
|---|---|---|---|
| Filtres admin | keyword, status, page, pageSize | reponse API complete | Separer URL et UI locale |
| Panier | SKU, quantite, prix affiche | session de paiement, stock reel | Persister seulement les champs peu risques |
| Auth UI | dialog de login, checking | token, email, adresse | Ne jamais stocker PII ou secrets |
| Modal/toast | activeModal, file courte de toasts | logs longs, audit | Garder uniquement ce que l’UI affiche |
| Optimistic update | requestId, snapshot precedent | verite finale serveur | Definir rollback et conflits |
Masa a deja rencontre un probleme sur un back-office: le but etait “utiliser Zustand” au lieu de clarifier l’etat. La premiere version stockait recherche, URL query, lignes recuperees, selection et toasts ensemble. Apres navigation, d’anciens filtres touchaient encore le nouvel ecran. La correction a ete une table de decision, pas un middleware de plus.
flowchart LR
UI[React components] --> Selectors[Selectors and useShallow]
Selectors --> Store[Zustand store]
Store --> Actions[Actions]
Actions --> API[API and server state]
Store --> Persist[persist partialize]
Persist --> Storage[localStorage safe fields]
Store TypeScript
Voici un store d’UI complet mais volontairement limite. Il couvre filtres admin, panier, auth UI, modal et toasts. Dans une grande application, vous pouvez le decouper; pour une premiere demande a Claude Code, un slice complet est plus simple a relire.
import { create, type StateCreator } from "zustand";
export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;
export interface AdminFilters {
keyword: string;
status: OrderStatus;
page: number;
pageSize: number;
}
export interface CartLine {
id: string;
name: string;
price: number;
quantity: number;
}
export interface AuthUi {
status: AuthUiStatus;
loginDialogOpen: boolean;
}
export interface Toast {
id: string;
kind: "success" | "error" | "info";
message: string;
}
export interface CommerceUiState {
filters: AdminFilters;
cart: CartLine[];
auth: AuthUi;
activeModal: ModalId;
toasts: Toast[];
setFilters: (patch: Partial<AdminFilters>) => void;
resetFilters: () => void;
addToCart: (line: Omit<CartLine, "quantity">) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
setAuthStatus: (status: AuthUiStatus) => void;
setLoginDialogOpen: (open: boolean) => void;
openModal: (modal: Exclude<ModalId, null>) => void;
closeModal: () => void;
pushToast: (toast: Omit<Toast, "id">) => void;
dismissToast: (id: string) => void;
checkoutTotal: () => number;
}
export const initialFilters: AdminFilters = {
keyword: "",
status: "all",
page: 1,
pageSize: 25,
};
const createToastId = () =>
globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
filters: initialFilters,
cart: [],
auth: { status: "anonymous", loginDialogOpen: false },
activeModal: null,
toasts: [],
setFilters: (patch) =>
set((state) => ({
filters: {
...state.filters,
...patch,
page: patch.page ?? 1,
},
})),
resetFilters: () => set({ filters: initialFilters }),
addToCart: (line) =>
set((state) => {
const current = state.cart.find((item) => item.id === line.id);
if (!current) {
return { cart: [...state.cart, { ...line, quantity: 1 }] };
}
return {
cart: state.cart.map((item) =>
item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
),
};
}),
removeFromCart: (id) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
cart: state.cart
.map((item) =>
item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
)
.filter((item) => item.quantity > 0),
})),
setAuthStatus: (status) =>
set((state) => ({
auth: { ...state.auth, status },
})),
setLoginDialogOpen: (open) =>
set((state) => ({
auth: { ...state.auth, loginDialogOpen: open },
})),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
pushToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: createToastId() }],
})),
dismissToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
checkoutTotal: () =>
get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});
export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);
Le choix important est de stocker l’etat visuel de l’authentification, pas les secrets. Token, email, adresse, nom legal et session de paiement restent hors de ce store. Dans la demande a Claude Code, dites explicitement que les PII ne doivent jamais etre persistees.
Selectors et rendus React
Un composant qui appelle useCommerceUiStore() sans selector s’abonne a tout le store. Un toast peut donc relancer le rendu du badge panier. Un selector choisit seulement la partie necessaire; useShallow evite des rendus quand un objet retourne les memes valeurs superficielles.
import { useShallow } from "zustand/react/shallow";
import {
useCommerceUiStore,
type CommerceUiState,
} from "./commerce-ui-store";
export const selectCartCount = (state: CommerceUiState) =>
state.cart.reduce((sum, item) => sum + item.quantity, 0);
export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();
export function CartBadge() {
const count = useCommerceUiStore(selectCartCount);
return <button type="button">Cart ({count})</button>;
}
export function AdminFilterSummary() {
const { filters, setFilters, resetFilters } = useCommerceUiStore(
useShallow((state) => ({
filters: state.filters,
setFilters: state.setFilters,
resetFilters: state.resetFilters,
})),
);
return (
<form>
<input
value={filters.keyword}
placeholder="Search orders"
onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
/>
<select
value={filters.status}
onChange={(event) =>
setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
}
>
<option value="all">All</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
<output>Page {filters.page}</output>
<button type="button" onClick={resetFilters}>
Reset
</button>
</form>
);
}
Dans la revue, demandez a Claude Code de remplacer les abonnements larges par des selectors nommes. Cette discipline compte surtout dans les dashboards ou tableaux, filtres, notifications et panneaux lateraux changent souvent.
Persist partiel
persist garde l’etat apres rechargement, mais il peut aussi exposer des donnees. On persiste le panier et les filtres, pas modal, toast, requestId, token ou PII. partialize limite ce qui part dans le storage.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createCommerceUiSlice,
type CommerceUiState,
} from "./commerce-ui-store";
const noopStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;
export const usePersistedCommerceUiStore = create<CommerceUiState>()(
persist(createCommerceUiSlice, {
name: "commerce-ui-v1",
version: 1,
storage: createJSONStorage(() =>
typeof window === "undefined" ? noopStorage : window.localStorage,
),
partialize: (state): PersistedCommerceUiState => ({
filters: state.filters,
cart: state.cart,
}),
}),
);
Avec SSR, le serveur ne lit pas localStorage. Si le HTML serveur affiche panier 0 et que le navigateur restaure 3 lignes, l’hydration peut diverger. Affichez ces valeurs apres mount ou sortez-les du markup critique.
Action async et mise a jour optimiste
Une mise a jour optimiste modifie l’ecran avant la confirmation serveur. Elle convient aux follow, like ou changements de quantite peu risques. Le store doit suivre un requestId et un snapshot precedent pour revenir en arriere si la requete echoue.
import { create } from "zustand";
interface Profile {
id: string;
name: string;
isFollowing: boolean;
}
interface ProfileState {
profiles: Record<string, Profile>;
followRequestIds: Record<string, string>;
setProfile: (profile: Profile) => void;
followOptimistically: (profileId: string) => Promise<void>;
}
const removeKey = <T,>(record: Record<string, T>, key: string) => {
const { [key]: _removed, ...rest } = record;
return rest;
};
async function updateFollowOnServer(profileId: string, follow: boolean) {
const response = await fetch(`/api/profiles/${profileId}/follow`, {
method: follow ? "PUT" : "DELETE",
});
if (!response.ok) {
throw new Error(`Follow update failed: ${response.status}`);
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
profiles: {},
followRequestIds: {},
setProfile: (profile) =>
set((state) => ({
profiles: { ...state.profiles, [profile.id]: profile },
})),
followOptimistically: async (profileId) => {
const before = get().profiles[profileId];
if (!before) {
throw new Error("Profile not found");
}
const requestId = `${profileId}-${Date.now()}`;
set((state) => ({
profiles: {
...state.profiles,
[profileId]: { ...before, isFollowing: true },
},
followRequestIds: {
...state.followRequestIds,
[profileId]: requestId,
},
}));
try {
await updateFollowOnServer(profileId, true);
} catch (error) {
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
profiles: { ...state.profiles, [profileId]: before },
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
throw error;
}
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
},
}));
Precisez a Claude Code si les clics repetes sont autorises, ce qui doit etre restaure en cas d’echec et quelle requete gagne en cas de concurrence.
Tests Vitest
Les actions Zustand se testent sans rendu React. On remet le store a l’etat initial avant chaque test, puis on verifie les contrats importants.
import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";
const initialState = useCommerceUiStore.getInitialState();
beforeEach(() => {
useCommerceUiStore.setState(initialState, true);
});
describe("commerce ui store", () => {
it("increments cart quantity and calculates total", () => {
const store = useCommerceUiStore.getState();
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
});
it("resets the page when a filter changes", () => {
useCommerceUiStore.getState().setFilters({ page: 4 });
useCommerceUiStore.getState().setFilters({ keyword: "refund" });
expect(useCommerceUiStore.getState().filters).toMatchObject({
keyword: "refund",
page: 1,
});
});
it("keeps auth UI and toasts explicit", () => {
useCommerceUiStore.getState().setAuthStatus("signedIn");
useCommerceUiStore.getState().pushToast({
kind: "success",
message: "Saved",
});
expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
});
});
Prompt de revue
Revoyez uniquement la conception Zustand de ce diff.
Verifier:
1. Les valeurs locales ne sont pas deplacees dans le global store.
2. Les composants utilisent des selectors.
3. persist partialize ne sauve pas PII, tokens, modal, toasts ou requestId.
4. SSR/hydration mismatch est traite.
5. Les actions async gerent anciennes reponses, clics repetes et rollback.
6. devtools et immer ont une raison concrete.
7. Vitest couvre les actions principales et un cas d'echec.
Retour:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge
Les pieges principaux sont: tout globaliser, oublier les selectors, persister des PII, ignorer SSR/hydration, ecrire des actions async sans gestion de concurrence, ajouter devtools ou immer par habitude. Le store doit clarifier les decisions produit, pas les cacher.
Claude Code Lab propose une formation et un accompagnement pour auditer un store React existant, separer Zustand et TanStack Query, definir les champs persistables et creer des prompts de revue reutilisables. Memo de verification: au 2 juin 2026, ces exemples ont ete relus avec la documentation officielle Zustand sur create, persist, useShallow et les tests. En pratique, une table des champs autorises a etre persistés reduit fortement les corrections de confidentialite et d’hydration.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.