React & State

Zustand mit Claude Code: Modernes React State Management 2026

Zustand hat sich als bevorzugte State-Management-Lösung im React-Ökosystem etabliert — minimales API, kein Provider-Boilerplate, volle TypeScript-Unterstützung. Mit Claude Code lassen sich Stores, Middleware-Ketten und Slice-Architekturen in Minuten entwerfen und testen. In diesem Guide zeige ich, wie du Zustand professionell einsetzt: von einfachen Stores bis hin zu komplexen Slice-Architekturen mit Immer, Persist und DevTools-Integration.

1. Zustand Grundlagen & Store-Setup

Zustand (japanisch für "Zustand" / "State") wurde von den Machern von Jotai und Valtio entwickelt und löst das klassische Redux-Boilerplate-Problem mit einem radikal einfachen API. Ein Store ist nichts anderes als eine Funktion, die set und get empfängt und ein Objekt zurückgibt.

Store Setup
// store/useCounterStore.ts import { create } from 'zustand' // TypeScript Interface für State + Actions interface CounterState { count: number step: number increment: () => void decrement: () => void incrementByStep: (amount: number) => void reset: () => void setStep: (step: number) => void } const useCounterStore = create<CounterState>()((set) => ({ count: 0, step: 1, increment: () => set((state) => ({ count: state.count + state.step })), decrement: () => set((state) => ({ count: state.count - state.step })), incrementByStep: (amount) => set((state) => ({ count: state.count + amount })), reset: () => set({ count: 0, step: 1 }), setStep: (step) => set({ step }), })) export default useCounterStore

Im Gegensatz zu Redux braucht Zustand keinen Provider, keine Reducer, keine Action Creators. Der Hook kann direkt in jeder Komponente genutzt werden — Claude Code generiert passende TypeScript-Interfaces automatisch auf Basis deiner Datenbeschreibung.

Selector Pattern
// components/Counter.tsx import { shallow } from 'zustand/shallow' import useCounterStore from '../store/useCounterStore' export function Counter() { // Einzelner Wert — re-renders nur wenn count sich ändert const count = useCounterStore((state) => state.count) // Mehrere Werte mit shallow equality — verhindert unnötige re-renders const { increment, decrement, reset } = useCounterStore( (state) => ({ increment: state.increment, decrement: state.decrement, reset: state.reset, }), shallow ) return ( <div> <h2>{count}</h2> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> <button onClick={reset}>Reset</button> </div> ) } // Alternativ: useShallow Hook (Zustand v5) import { useShallow } from 'zustand/react/shallow' const { step, setStep } = useCounterStore( useShallow((state) => ({ step: state.step, setStep: state.setStep })) )
Claude Code Prompt-Tipp: Beschreibe deinen State als TypeScript-Interface und frage Claude Code nach einem vollständigen Zustand-Store — inklusive Selektoren, Shallow-Equality-Checks und Reset-Pattern. Claude generiert produktionsreifen Code in einem Schritt.
Merkmal Zustand Redux Toolkit Jotai
Bundle-Größe~1 KB~40 KB~3 KB
Provider nötigNeinJaNein
BoilerplateMinimalMittelMinimal
DevToolsVia MiddlewareEingebautVia Plugin
TypeScriptErstklassigGutErstklassig
MiddlewareComposableRedux MiddlewareAtoms

2. Actions & Computed Values

Zustand's set-Funktion ist der Kern aller State-Mutationen. Anders als bei Redux Reducern kannst du set direkt mit einem Teil-State oder einer Callback-Funktion aufrufen. Mit get greifst du auf den aktuellen State zu — ideal für Computed Values und komplexe Action-Factories.

Actions & Get
// store/useCartStore.ts import { create } from 'zustand' interface CartItem { id: string name: string price: number quantity: number } interface CartState { items: CartItem[] isOpen: boolean // Computed (abgeleitete Werte als Getter-Funktionen) totalItems: () => number totalPrice: () => number // Actions addItem: (item: Omit<CartItem, 'quantity'>) => void removeItem: (id: string) => void updateQuantity: (id: string, quantity: number) => void clearCart: () => void toggleCart: () => void } const useCartStore = create<CartState>()((set, get) => ({ items: [], isOpen: false, // Computed Values via get() — immer frischer State totalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0), totalPrice: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0), addItem: (newItem) => set((state) => { const existing = state.items.find((i) => i.id === newItem.id) if (existing) { return { items: state.items.map((i) => i.id === newItem.id ? { ...i, quantity: i.quantity + 1 } : i ), } } return { items: [...state.items, { ...newItem, quantity: 1 }] } }), removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id), })), updateQuantity: (id, quantity) => set((state) => ({ items: quantity <= 0 ? state.items.filter((i) => i.id !== id) : state.items.map((i) => (i.id === id ? { ...i, quantity } : i)), })), clearCart: () => set({ items: [] }), toggleCart: () => set((state) => ({ isOpen: !state.isOpen })), })) export default useCartStore
Reset Pattern & Action Factories
// Reset-Pattern: initialen State als Konstante separieren const initialCartState = { items: [] as CartItem[], isOpen: false, } // In store: reset: () => set(initialState) // Garantiert konsistenten Reset ohne State-Drift bei komplexen Updates // Wiederverwendbare Action-Factory für CRUD-Operationen type SetFn<T> = (fn: (state: { items: T[] }) => Partial<{ items: T[] }>) => void function createEntityActions<T extends { id: string }>(set: SetFn<T>) { return { addEntity: (item: T) => set((state) => ({ items: [...state.items, item] })), updateEntity: (id: string, updates: Partial<T>) => set((state) => ({ items: state.items.map((i) => i.id === id ? { ...i, ...updates } : i ), })), deleteEntity: (id: string) => set((state) => ({ items: state.items.filter((i) => i.id !== id), })), resetEntities: () => set(() => ({ items: [] })), } } // setState Callback: Zugriff auf vorherigen State ohne set(fn) // Nützlich außerhalb von React für direkte Store-Manipulation useCartStore.setState((prev) => ({ items: prev.items.filter((i) => i.quantity > 0), }))
Best Practice: Separiere den initialen State in eine Konstante und nutze set(initialState) für Resets. So behält dein Store stets einen definierten Ausgangszustand — wichtig für Tests und Logout-Flows.

3. Middleware: Persist & DevTools

Zustand's Middleware-System ist composable — mehrere Middlewares werden einfach verschachtelt. Die wichtigsten eingebauten Middlewares sind persist für localStorage-Persistenz und devtools für die Redux DevTools Extension-Integration.

Persist + DevTools
// store/useSettingsStore.ts — Persist + DevTools kombiniert import { create } from 'zustand' import { persist, devtools, createJSONStorage } from 'zustand/middleware' interface SettingsState { theme: 'light' | 'dark' | 'system' language: string notifications: boolean fontSize: number // Transiente Felder — nicht persistieren isLoading: boolean error: string | null setTheme: (theme: SettingsState['theme']) => void setLanguage: (lang: string) => void toggleNotifications: () => void setFontSize: (size: number) => void } const useSettingsStore = create<SettingsState>()( devtools( persist( (set) => ({ theme: 'system', language: 'de', notifications: true, fontSize: 16, isLoading: false, error: null, // Drittes Argument: Action-Name für DevTools-Panel setTheme: (theme) => set({ theme }, false, 'settings/setTheme'), setLanguage: (language) => set({ language }, false, 'settings/setLanguage'), toggleNotifications: () => set( (state) => ({ notifications: !state.notifications }), false, 'settings/toggleNotifications' ), setFontSize: (fontSize) => set({ fontSize }, false, 'settings/setFontSize'), }), { name: 'app-settings', // localStorage Key storage: createJSONStorage(() => localStorage), // partialize: nur bestimmte Felder persistieren partialize: (state) => ({ theme: state.theme, language: state.language, notifications: state.notifications, fontSize: state.fontSize, // isLoading + error werden NICHT gespeichert }), version: 1, migrate: (persistedState, version) => { if (version === 0) { return { ...(persistedState as SettingsState), fontSize: 16 } } return persistedState as SettingsState }, } ), { name: 'SettingsStore', enabled: process.env.NODE_ENV === 'development', } ) ) export default useSettingsStore

Das dritte Argument von set innerhalb von devtools ist der Action-Name, der in den Redux DevTools angezeigt wird. So sieht man im Dev-Panel genau, welche Action welchen State verändert hat — ohne Redux-Overhead.

Custom Storage & SSR Hydration
// sessionStorage für tab-gebundene Persistenz const useSessionStore = create<SessionState>()( persist( (set) => ({ /* state + actions */ }), { name: 'session-data', storage: createJSONStorage(() => sessionStorage), } ) ) // Custom Storage via IndexedDB (idb-keyval) import { get, set, del } from 'idb-keyval' const indexedDBStorage = { getItem: async (name: string) => (await get(name)) ?? null, setItem: async (name: string, value: string) => { await set(name, value) }, removeItem: async (name: string) => { await del(name) }, } // SSR: Hydration-Status prüfen (Next.js) const hasHydrated = useSettingsStore.persist.hasHydrated() // Manuell rehydrieren nach SSR await useSettingsStore.persist.rehydrate() // useHydration Hook — Skeleton anzeigen bis Store geladen function useHydration() { const [hydrated, setHydrated] = useState(false) useEffect(() => { const unsub = useSettingsStore.persist.onFinishHydration(() => setHydrated(true)) setHydrated(useSettingsStore.persist.hasHydrated()) return unsub }, []) return hydrated }
SSR-Hinweis: Bei Next.js musst du auf die Hydration warten, bevor du persistierten State rendert. Nutze hasHydrated() und zeige einen Skeleton an, bis der Store geladen ist. Claude Code generiert auf Anfrage einen vollständigen useHydration-Hook.

4. Immer-Integration

Immer ermöglicht direkte Mutationen im State — der produce-Mechanismus erstellt automatisch einen neuen immutablen State. Mit der Zustand Immer-Middleware schreibst du Code wie bei einem mutablen Objekt, erhältst aber vollständige Immutabilität. Besonders bei tiefer Verschachtelung ist das ein enormer Gewinn.

Immer Middleware
// store/useTreeStore.ts — Immer für verschachtelte State-Updates import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' interface TreeNode { id: string label: string isExpanded: boolean isSelected: boolean children: TreeNode[] } interface TreeState { root: TreeNode[] selectedId: string | null toggleExpand: (id: string) => void selectNode: (id: string) => void updateLabel: (id: string, label: string) => void addChild: (parentId: string, child: TreeNode) => void } // Hilfsfunktion: Knoten im Baum finden (rekursiv) function findNode(nodes: TreeNode[], id: string): TreeNode | undefined { for (const node of nodes) { if (node.id === id) return node const found = findNode(node.children, id) if (found) return found } } const useTreeStore = create<TreeState>()( immer((set) => ({ root: [], selectedId: null, // Mit Immer: direkte Mutation — kein Spread-Operator nötig! toggleExpand: (id) => set((state) => { const node = findNode(state.root, id) if (node) node.isExpanded = !node.isExpanded }), selectNode: (id) => set((state) => { // Alle Nodes deselektieren (rekursiv) function deselect(nodes: TreeNode[]) { for (const n of nodes) { n.isSelected = false deselect(n.children) } } deselect(state.root) const node = findNode(state.root, id) if (node) { node.isSelected = true state.selectedId = id } }), updateLabel: (id, label) => set((state) => { const node = findNode(state.root, id) if (node) node.label = label }), addChild: (parentId, child) => set((state) => { const parent = findNode(state.root, parentId) if (parent) parent.children.push(child) }), })) ) export default useTreeStore
Immer vs. Spread — Vergleich
// OHNE Immer — verschachtelte Updates sind fehleranfällig: updateLabel: (id, label) => set((state) => ({ root: state.root.map((node) => node.id === id ? { ...node, label } : { ...node, children: node.children.map((child) => child.id === id ? { ...child, label } : { ...child } // Exponentieller Spread bei tiefer Nesting ), } ), })) // MIT Immer — klar, lesbar, korrekt: updateLabel: (id, label) => set((state) => { const node = findNode(state.root, id) if (node) node.label = label // Direktzugriff — Immer macht Immutabilität }) // Middleware-Kombination: Persist + Immer + DevTools const store = create<State>()( devtools( persist( immer((set, get) => ({ /* state + actions */ })), { name: 'store-key' } ), { name: 'MyStore' } ) )
Performance-Hinweis: Immer hat einen kleinen Overhead durch den Proxy-Mechanismus. Für flache State-Strukturen ist direktes Spread meistens performanter. Setze Immer gezielt bei tiefer Verschachtelung ein — dort wo der Gewinn an Lesbarkeit den Overhead rechtfertigt.

5. Slices-Pattern für große Apps

Ab einer gewissen Größe wird ein einzelner Store unübersichtlich. Das Slices-Pattern teilt den Store in logische Einheiten — jede mit eigenem State, eigenen Actions und eigenem TypeScript-Interface. Die Slices werden dann zu einem kombinierten Store zusammengeführt. Claude Code generiert auf Basis eines Entity-Diagramms vollständige Slice-Architekturen.

User Slice
// store/slices/userSlice.ts import { StateCreator } from 'zustand' export interface UserSlice { user: { id: string | null name: string email: string role: 'admin' | 'user' | 'guest' avatar: string | null } isAuthenticated: boolean setUser: (user: UserSlice['user']) => void clearUser: () => void updateProfile: (updates: Partial<UserSlice['user']>) => void } const initialUser = { id: null, name: '', email: '', role: 'guest' as const, avatar: null, } export const createUserSlice: StateCreator< UserSlice, // Dieser Slice [], // Middlewares (leer — werden im combined store definiert) [], UserSlice > = (set) => ({ user: initialUser, isAuthenticated: false, setUser: (user) => set({ user, isAuthenticated: true }), clearUser: () => set({ user: initialUser, isAuthenticated: false }), updateProfile: (updates) => set((state) => ({ user: { ...state.user, ...updates } })), })
UI Slice & Combine
// store/slices/uiSlice.ts export interface UISlice { sidebar: { isOpen: boolean; width: number } modal: { isOpen: boolean; type: string | null; data: unknown } toast: { messages: Array<{ id: string; text: string; type: 'success' | 'error' | 'info' }> } toggleSidebar: () => void openModal: (type: string, data?: unknown) => void closeModal: () => void addToast: (text: string, type: 'success' | 'error' | 'info') => void removeToast: (id: string) => void } export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set) => ({ sidebar: { isOpen: true, width: 280 }, modal: { isOpen: false, type: null, data: null }, toast: { messages: [] }, toggleSidebar: () => set((state) => ({ sidebar: { ...state.sidebar, isOpen: !state.sidebar.isOpen } })), openModal: (type, data = null) => set({ modal: { isOpen: true, type, data } }), closeModal: () => set({ modal: { isOpen: false, type: null, data: null } }), addToast: (text, type) => set((state) => ({ toast: { messages: [...state.toast.messages, { id: crypto.randomUUID(), text, type }], }, })), removeToast: (id) => set((state) => ({ toast: { messages: state.toast.messages.filter((m) => m.id !== id) }, })), }) // store/useBoundStore.ts — Slices zu einem Store kombinieren import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { UserSlice, createUserSlice } from './slices/userSlice' import { UISlice, createUISlice } from './slices/uiSlice' type BoundStore = UserSlice & UISlice export const useBoundStore = create<BoundStore>()( devtools( persist( (...args) => ({ ...createUserSlice(...args), ...createUISlice(...args), }), { name: 'app-store', // Nur User-Daten persistieren, UI-State ist transient partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated, }), } ), { name: 'BoundStore' } ) )
Slice-Isolation: Jede Slice-Datei sollte nur auf ihren eigenen State zugreifen. Cross-Slice-Abhängigkeiten über get() funktionieren, sollten aber sparsam eingesetzt werden, um die Testbarkeit zu erhalten. Claude Code kann Slice-Interfaces aus einem Datenmodell automatisch ableiten.

6. Async Actions & Side Effects

Async-State ist in Zustand direkt in den Actions integriert — keine Thunks, kein createAsyncThunk. Du verwaltest Loading-, Error- und Data-State explizit im Store, was volle Kontrolle über Lifecycle und Fehlerbehandlung gibt. Besonders mächtig: Optimistic Updates mit automatischem Rollback.

Async Actions
// store/useProductStore.ts — Async mit AbortController import { create } from 'zustand' interface Product { id: string; name: string; price: number; stock: number } interface ProductState { products: Product[] selectedProduct: Product | null isLoading: boolean error: string | null fetchProducts: (categoryId: string, signal?: AbortSignal) => Promise<void> fetchProduct: (id: string) => Promise<void> updateProductStock: (id: string, stock: number) => Promise<void> clearError: () => void } const useProductStore = create<ProductState>()((set, get) => ({ products: [], selectedProduct: null, isLoading: false, error: null, fetchProducts: async (categoryId, signal) => { set({ isLoading: true, error: null }) try { const res = await fetch(`/api/products?categoryId=${categoryId}`, { signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const products = await res.json() as Product[] set({ products, isLoading: false }) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { set({ isLoading: false }) // Kein Fehler bei bewusstem Abbruch return } set({ error: (err as Error).message, isLoading: false }) } }, fetchProduct: async (id) => { // Cache-first: lokalen Store prüfen bevor Netzwerk-Request const cached = get().products.find((p) => p.id === id) if (cached) { set({ selectedProduct: cached }); return } set({ isLoading: true, error: null }) try { const res = await fetch(`/api/products/${id}`) const product = await res.json() as Product set({ selectedProduct: product, isLoading: false }) } catch (err) { set({ error: (err as Error).message, isLoading: false }) } }, // Optimistic Update: UI sofort aktualisieren, Rollback bei Fehler updateProductStock: async (id, stock) => { const previousProducts = get().products // Snapshot für Rollback set((state) => ({ products: state.products.map((p) => (p.id === id ? { ...p, stock } : p)), })) try { const res = await fetch(`/api/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stock }), }) if (!res.ok) throw new Error('Update fehlgeschlagen') } catch (err) { set({ products: previousProducts, error: (err as Error).message }) } }, clearError: () => set({ error: null }), })) export default useProductStore
React Integration & Outside Access
// React-Komponente mit korrektem AbortController Cleanup import { useEffect } from 'react' export function ProductList({ categoryId }: { categoryId: string }) { const { products, isLoading, error, fetchProducts, clearError } = useProductStore() useEffect(() => { const controller = new AbortController() fetchProducts(categoryId, controller.signal) return () => { controller.abort() // Cleanup: laufende Requests bei Unmount abbrechen clearError() } }, [categoryId]) if (isLoading) return <div>Lädt...</div> if (error) return <div>Fehler: {error}</div> return <ul>{products.map((p) => <li key={p.id}>{p.name} — {p.price} €</li>)}</ul> } // Außerhalb von React: Subscribe auf State-Änderungen const unsubscribe = useProductStore.subscribe( (state) => state.products, // Selector (products, prevProducts) => { // Listener console.log(`Products: ${prevProducts.length} → ${products.length}`) } ) unsubscribe() // Cleanup aufrufen wenn nicht mehr gebraucht // Einmaliger State-Zugriff ohne Re-render-Subscription const currentProducts = useProductStore.getState().products // Direkte Mutation von außen (z.B. in Event-Handlern) useProductStore.setState((prev) => ({ products: prev.products.filter((p) => p.stock > 0), }))
Store Testing
// __tests__/useProductStore.test.ts import { renderHook, act } from '@testing-library/react' import { beforeEach, describe, it, expect, vi } from 'vitest' import useProductStore from '../store/useProductStore' // Store vor jedem Test auf initialen State zurücksetzen beforeEach(() => { useProductStore.setState({ products: [], selectedProduct: null, isLoading: false, error: null, }) }) describe('useProductStore', () => { it('fetches products and updates state', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([ { id: '1', name: 'Produkt A', price: 9.99, stock: 5 } ]), }) const { result } = renderHook(() => useProductStore()) await act(async () => { await result.current.fetchProducts('cat-1') }) expect(result.current.products).toHaveLength(1) expect(result.current.isLoading).toBe(false) expect(result.current.error).toBeNull() }) it('rolls back optimistic update on API failure', async () => { useProductStore.setState({ products: [{ id: '1', name: 'Test', price: 9.99, stock: 10 }], }) global.fetch = vi.fn().mockResolvedValue({ ok: false }) const { result } = renderHook(() => useProductStore()) await act(async () => { await result.current.updateProductStock('1', 5) }) // Rollback: Stock muss wieder original 10 sein expect(result.current.products[0].stock).toBe(10) expect(result.current.error).not.toBeNull() }) })
Test-Strategie: Nutze useProductStore.setState() direkt, um den Store in Test-Zustände zu versetzen. Kein Mocking von React-Context nötig. Claude Code schreibt passende Test-Suites auf Basis deines Store-Interface — inklusive Optimistic-Update-Rollback-Tests und AbortController-Cleanup-Verifikation.

Fazit: Zustand ist die richtige Wahl für moderne React-Apps

Zustand vereint das Beste aus beiden Welten: Die Einfachheit von React Context und die Leistungsfähigkeit von Redux — ohne den Boilerplate-Overhead. Mit Claude Code lässt sich eine vollständige Store-Architektur in Minuten entwerfen:

Der Schlüssel liegt im Selector-Pattern mit shallow oder useShallow — damit rendert jede Komponente nur, wenn sich ihre relevanten State-Teile ändern. Kombiniert mit dem Slices-Pattern ergibt sich eine skalierbare Architektur, die auch in großen Teams wartbar bleibt. Claude Code beschleunigt dabei jeden Schritt — vom ersten Store-Entwurf bis zu vollständigen Test-Suites.

State-Modul im Kurs

Im Claude Code Mastery Kurs: vollständiges Zustand-Modul mit Persist, DevTools, Immer, Slices-Pattern und Async-State für produktionsreife React-Apps.

14 Tage kostenlos testen →