Use Cases (Mis à jour: 02/06/2026)

Gerer l'etat Zustand avec Claude Code

Concevez un store Zustand avec Claude Code: selectors, persist partiel, actions async, tests et prompt de revue.

Gerer l'etat Zustand avec Claude Code

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.

CasDans ZustandHors ZustandInstruction a Claude Code
Filtres adminkeyword, status, page, pageSizereponse API completeSeparer URL et UI locale
PanierSKU, quantite, prix affichesession de paiement, stock reelPersister seulement les champs peu risques
Auth UIdialog de login, checkingtoken, email, adresseNe jamais stocker PII ou secrets
Modal/toastactiveModal, file courte de toastslogs longs, auditGarder uniquement ce que l’UI affiche
Optimistic updaterequestId, snapshot precedentverite finale serveurDefinir 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.

#Claude Code #Zustand #React #state management #TypeScript
Gratuit

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.