Use Cases (Aktualisiert: 2.6.2026)

Zustand State Management mit Claude Code

Entwirf Zustand Stores mit Claude Code: Selectors, persist Partialize, async Actions, Tests und Review Prompts.

Zustand State Management mit Claude Code

Erst die Grenze des State festlegen

Zustand ist eine schlanke State-Management-Bibliothek fur React. State Management bedeutet nicht, jede Variable in ein globales Objekt zu legen. Es bedeutet, festzulegen, welche Werte mehrere Komponenten teilen, welche Actions diese Werte andern duerfen und welche Werte lokal im Component State bleiben. Claude Code kann sehr schnell einen Store generieren, braucht aber klare Grenzen. Ohne diese Grenzen landen Formulartexte, Auth-Daten, API-Antworten, Toasts und Warenkorb im selben globalen Store.

Dieser Artikel nutzt Claude Code als Partner fuer State Design. Die Beispiele behandeln Admin-Filter, Warenkorb, Auth-UI-State, Modal/Toast und optimistische Updates. Enthalten sind kopierbare Codebloecke fuer TypeScript Store, Selectors, persist mit partialize, async Action, Vitest Tests und ein Review Prompt.

Technische Basis sind die offizielle Zustand Einfuehrung, die Referenz zu persist middleware und der useShallow Guide. Die Trennung von Server State wird im TanStack Query Guide erklaert. Fuer feinere State-Einheiten lohnt der Vergleich mit Jotai atoms.

Was in Zustand gehoert

Die praktische Regel: Nutze Zustand, wenn weit entfernte Komponenten denselben Wert brauchen, wenn Update-Logik zentral getestet werden soll oder wenn URL-State nicht ausreicht. Speichere nicht jede Serverantwort in Zustand. Produktlisten, Nutzerlisten und Suchergebnisse brauchen Cache, Refetch, Stale-Regeln und Fehlerbehandlung; das ist meist Aufgabe einer Server-State-Library.

Use CaseIn ZustandNicht in ZustandHinweis an Claude Code
Admin-Filterkeyword, status, page, pageSizekomplette API-AntwortURL-Werte und UI-Werte trennen
WarenkorbSKU, Menge, sichtbarer PreisPayment Session, wahrer LagerbestandNur risikoarme Felder persistieren
Auth UILogin-Dialog, checking StatusToken, E-Mail, AdresseKeine PII oder Secrets speichern
Modal/toastactiveModal, kurze Toast-QueueAudit Logs, lange FehlerNur renderbare UI-Info speichern
Optimistic updaterequestId, vorheriger Snapshotfinale Server-WahrheitRollback und Konkurrenz regeln

Masa hatte in einem Admin-Dashboard genau diesen Fehler: Das Ziel war “Zustand einsetzen” statt State sauber zu schneiden. Die erste Version speicherte Sucheingabe, URL Query, geladene Tabellenzeilen, Selektion und Toasts zusammen. Nach Navigation beeinflussten alte Filter die neue Ansicht. Die Loesung war eine State-Inventur, nicht ein weiteres Middleware-Paket.

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]

TypeScript Store

Der folgende Store ist bewusst eng gefasst: Admin-Filter, Warenkorb, Auth UI, Modal und Toasts. In groesseren Apps kann man Dateien trennen; fuer die erste Claude-Code-Anfrage ist ein vollstaendiger Slice leichter zu pruefen.

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);

Wichtig ist: Der Store haelt Auth-UI-State, nicht Auth-Secrets. Access Token, E-Mail, Adresse, rechtlicher Name und Payment Session gehoeren nicht hinein. Wenn Claude Code persist ergaenzen soll, sage explizit, dass PII niemals gespeichert werden darf.

Selectors fuer stabile Renderings

Ein Component mit useCommerceUiStore() abonniert den ganzen Store. Ein neuer Toast kann dann den Warenkorb-Button neu rendern. Ein Selector liest nur den benoetigten Ausschnitt. Wenn ein Selector ein Objekt zurueckgibt, verhindert useShallow unnoetige Updates bei gleichen flachen Werten.

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>
  );
}

Bitte Claude Code, alle Store-Imports zu pruefen. Keine echte UI-Komponente sollte den kompletten Store abonnieren, ausser sie ist ein Debug-Panel. Diese Disziplin zahlt sich aus, sobald Tabellen, Sidebar, Filter und Notifications gleichzeitig laufen.

Persist nur fuer sichere Felder

persist ist nuetzlich, kann aber Datenschutzprobleme schaffen. Warenkorb und Filter sind gute Kandidaten. Modal, Toast, Login-Dialog, requestId, Token und PII sind es nicht. partialize begrenzt die gespeicherten Felder.

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,
    }),
  }),
);

Bei SSR kann der Server localStorage nicht lesen. Wenn serverseitig Warenkorb 0 gerendert wird und der Browser danach 3 Positionen wiederherstellt, droht ein Hydration-Mismatch. Solche Werte erst nach Mount anzeigen oder aus kritischem Markup herausloesen.

Async Action und optimistic update

Optimistische Updates aendern die UI, bevor der Server bestaetigt. Das passt fuer Follow, Like oder risikoarme Mengenwechsel. Gefahrlich sind Rennen zwischen alten und neuen Antworten. Ein requestId und der vorherige Snapshot sind die Basis fuer Rollback.

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),
      };
    });
  },
}));

Gib Claude Code Produktregeln: Sind Mehrfachklicks erlaubt, was wird bei Fehler zurueckgesetzt, erscheint ein Toast, und welche Anfrage gewinnt?

Vitest Tests

Zustand Actions lassen sich ohne React Rendering testen. Wichtig ist ein Reset vor jedem Test und klare Erwartungen.

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);
  });
});

Review Prompt

Review only the Zustand state-management design in this diff.

Check:
1. Local state was not moved into global store without need.
2. Components use selectors instead of the whole store.
3. persist partialize does not save PII, tokens, modal, toasts, or requestId.
4. SSR/hydration mismatch is handled.
5. Async actions handle stale responses, repeated clicks, and rollback.
6. devtools and immer have a concrete reason.
7. Vitest covers main actions and one failure path.

Return:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge

Die haeufigsten Fallen sind: alles globalisieren, Selectors vergessen, PII persistieren, SSR/hydration ignorieren, async Rennen nicht absichern und devtools oder immer aus Gewohnheit hinzufuegen. Der Store soll Entscheidungen sichtbar machen, nicht verstecken.

Claude Code Lab bietet Training und Beratung, um bestehende React Stores zu auditieren, Zustand von TanStack Query zu trennen, sichere Persist-Regeln zu definieren und Review Prompts fuer Teams zu standardisieren. Verifikationsnotiz: Am 2. Juni 2026 wurden die Beispiele gegen die offiziellen Zustand-Dokumente zu create, persist, useShallow und Testing geprueft. In echten Projekten reduziert eine Tabelle erlaubter Persist-Felder spaetere Datenschutz- und Hydration-Korrekturen deutlich.

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

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.