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ötig | Nein | Ja | Nein |
| Boilerplate | Minimal | Mittel | Minimal |
| DevTools | Via Middleware | Eingebaut | Via Plugin |
| TypeScript | Erstklassig | Gut | Erstklassig |
| Middleware | Composable | Redux Middleware | Atoms |
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:
- Store-Setup: TypeScript-Interface beschreiben → Claude Code generiert Store mit Selektoren und Shallow-Equality
- Actions: Computed-Values, Reset-Pattern und Action-Factories werden automatisch korrekt implementiert
- Middleware: Persist-Konfiguration mit
partialize, DevTools-Integration und Migration-Handlers
- Immer: Tiefe State-Updates ohne Spread-Kaskaden — Immer erledigt die Immutabilität
- Slices:
StateCreator-basierte Architektur für skalierbare, testbare Stores
- Async: Loading/Error-State, AbortController-Integration und Optimistic Updates mit Rollback
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 →