1. Zustand Grundlagen: create, useStore, set & get
Zustand (deutsch für "state") wurde 2019 von Daishi Kato bei Poimandres entwickelt und hat sich
zur Standardlösung für React State Management außerhalb der React Context API entwickelt.
Die Library kommt in der Bundle-Größe auf unter 1 kB (gzipped) — das ist kleiner als die meisten
Icon-Libraries.
Mit Claude Code lässt sich ein typsicherer Zustand-Store in Sekunden scaffolden. Claude versteht
die Zustand-API vollständig und schlägt automatisch TypeScript-Interfaces, korrekte Selektoren und
passende Middleware-Kombinationen vor.
Installation
# Zustand installieren
npm install zustand
# TypeScript-Support ist eingebaut — kein @types nötig
# Optional: immer für nested state updates
npm install immer
Grundlagen
Der minimale Zustand-Store
Ein Store besteht aus State-Daten und Aktionen — alles in einem einzigen create-Call.
// store/counter.ts
import { create } from 'zustand'
// TypeScript Interface für den Store
interface CounterState {
count: number
step: number
increment: () => void
decrement: () => void
incrementByAmount: (amount: number) => void
reset: () => void
setStep: (step: number) => void
}
export const useCounterStore = create<CounterState>()((set, get) => ({
count: 0,
step: 1,
// set() ersetzt State-Teile (shallow merge)
increment: () => set((state) => ({ count: state.count + state.step })),
decrement: () => set((state) => ({ count: state.count - state.step })),
incrementByAmount: (amount) => set((state) => ({ count: state.count + amount })),
reset: () => set({ count: 0 }),
// get() liest aktuellen State ohne Subscription
setStep: (step) => {
const current = get().count
console.log(`Step von ${current} auf ${step} gesetzt`)
set({ step })
},
}))
Verwendung in React-Komponenten
// components/Counter.tsx
import { useCounterStore } from '../store/counter'
export function Counter() {
// Nur die benötigten State-Teile abonnieren
const count = useCounterStore((state) => state.count)
const step = useCounterStore((state) => state.step)
const { increment, decrement, reset, setStep } = useCounterStore()
return (
<div>
<p>Count: {count} | Step: {step}</p>
<button onClick={increment}>+ {step}</button>
<button onClick={decrement}>- {step}</button>
<button onClick={() => setStep(5)}>Step = 5</button>
<button onClick={reset}>Reset</button>
</div>
)
}
Claude Code Tipp: Wenn du Claude Code sagst "Erstelle einen Zustand-Store für einen Shopping Cart mit TypeScript", generiert es automatisch das Interface, alle CRUD-Aktionen und passende Selektoren — inklusive Getter-Methoden via get().
TypeScript: Vollständig typisierte Store-Definition
Zustand 5.x (aktuelle Version 2026) unterstützt vollständiges TypeScript-Inference.
Der doppelte Funktionsaufruf create<T>()() ist notwendig, damit TypeScript
die generischen Typen korrekt ableiten kann — das ist kein Bug, sondern ein bekanntes TypeScript-Limitation-Workaround.
// store/types.ts — Shared Types für den Store
export interface Product {
id: string
name: string
price: number
quantity: number
imageUrl: string
}
export interface CartState {
items: Product[]
isOpen: boolean
couponCode: string | null
// Computed (derived) values als Getter-Funktionen
getTotalPrice: () => number
getItemCount: () => number
// Actions
addItem: (product: Product) => void
removeItem: (id: string) => void
updateQuantity: (id: string, qty: number) => void
applyCoupon: (code: string) => Promise<boolean>
clearCart: () => void
}
// store/cart.ts
import { create } from 'zustand'
import type { CartState, Product } from './types'
export const useCartStore = create<CartState>()((set, get) => ({
items: [],
isOpen: false,
couponCode: null,
getTotalPrice: () => {
const { items, couponCode } = get()
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
return couponCode === 'SAVE10' ? total * 0.9 : total
},
getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),
addItem: (product) => set((state) => {
const existing = state.items.find((i) => i.id === product.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
),
}
}
return { items: [...state.items, { ...product, quantity: 1 }] }
}),
removeItem: (id) => set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, qty) => set((state) => ({
items: qty <= 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) => i.id === id ? { ...i, quantity: qty } : i),
})),
applyCoupon: async (code) => {
const valid = ['SAVE10', 'WELCOME'].includes(code)
set({ couponCode: valid ? code : null })
return valid
},
clearCart: () => set({ items: [], couponCode: null }),
}))
2. Selektoren & Performance-Optimierung
Eine häufige Falle bei Zustand-Einsteigern: Das ganze Store-Objekt abonnieren, was bei
jeder State-Änderung einen Re-render auslöst — auch wenn sich der relevante Wert gar nicht geändert hat.
Zustand bietet mehrere Strategien, um dieses Problem zu lösen.
Performance
Warum Selektoren wichtig sind
Ohne Selektor re-rendert eine Komponente bei jeder State-Änderung im Store.
Mit präzisen Selektoren re-rendert sie nur, wenn sich der selektierte Wert ändert.
Granulare Selektoren
// ❌ Schlecht: Ganzen Store abonnieren
const store = useCartStore() // Re-render bei JEDER Änderung
// ✅ Besser: Nur benötigte Felder selektieren
const itemCount = useCartStore((state) => state.getItemCount())
const isOpen = useCartStore((state) => state.isOpen)
// ✅ Optimal: Stabile Referenzen für Actions
const addItem = useCartStore((state) => state.addItem)
// Actions sind stabile Referenzen — kein Memoisierungsaufwand nötig
Mehrere Felder mit shallow
// store/user.ts
import { create } from 'zustand'
import { shallow } from 'zustand/shallow'
interface UserState {
name: string
email: string
role: 'admin' | 'user' | 'guest'
avatar: string
lastLogin: Date | null
updateProfile: (data: Partial<Pick<UserState, 'name' | 'email'>>) => void
}
export const useUserStore = create<UserState>()((set) => ({
name: '',
email: '',
role: 'guest',
avatar: '',
lastLogin: null,
updateProfile: (data) => set(data),
}))
// components/UserHeader.tsx
function UserHeader() {
// shallow verhindert Re-render wenn sich nur andere Felder ändern
const { name, email, avatar } = useUserStore(
(state) => ({ name: state.name, email: state.email, avatar: state.avatar }),
shallow
)
return <div>{name} ({email})</div>
}
subscribeWithSelector: Reaktiv außerhalb von React
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create<CartState>()(
subscribeWithSelector((set, get) => ({
// ... store definition
}))
)
// Außerhalb von React: auf spezifische State-Änderungen reagieren
const unsubscribe = useStore.subscribe(
(state) => state.items.length, // Selektor
(count, prevCount) => { // Callback
if (count > prevCount) {
analytics.track('item_added', { count })
}
},
{ equalityFn: (a, b) => a === b, fireImmediately: false }
)
// Cleanup in useEffect
useEffect(() => unsubscribe, [])
Achtung: Referenz-Gleichheit bei Objekten und Arrays — Zustand nutzt Object.is für Vergleiche. Arrays werden als neue Referenz erkannt, auch wenn der Inhalt gleich ist. Nutze shallow oder eigene Equality-Funktionen.
Middleware in Zustand wrappen den create-Call und erweitern das Store-Verhalten.
Die zwei wichtigsten Built-in-Middlewares sind persist (localStorage/sessionStorage)
und devtools (Redux DevTools Integration).
Middleware
persist + devtools kombinieren
Middleware wird von innen nach außen geschachtelt. Die Reihenfolge ist relevant:
devtools(persist(...)) ist der Standard-Pattern für Production-Stores.
// store/settings.ts — persist + devtools
import { create } from 'zustand'
import { persist, devtools, createJSONStorage } from 'zustand/middleware'
interface SettingsState {
theme: 'light' | 'dark' | 'system'
language: string
notifications: boolean
sidebarCollapsed: boolean
setTheme: (theme: SettingsState['theme']) => void
toggleSidebar: () => void
setLanguage: (lang: string) => void
}
export const useSettingsStore = create<SettingsState>()(
devtools(
persist(
(set) => ({
theme: 'system',
language: 'de',
notifications: true,
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }, false, 'settings/setTheme'),
toggleSidebar: () => set(
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
false,
'settings/toggleSidebar'
),
setLanguage: (language) => set({ language }, false, 'settings/setLanguage'),
}),
{
name: 'app-settings', // localStorage Key
storage: createJSONStorage(() => localStorage),
// Nur bestimmte Felder persistieren
partialize: (state) => ({
theme: state.theme,
language: state.language,
notifications: state.notifications,
// sidebarCollapsed wird NICHT persistiert
}),
version: 1,
// Migration bei Store-Versions-Upgrades
migrate: (persistedState: any, version: number) => {
if (version === 0) {
// Von Version 0 auf 1: 'dark-mode' boolean → theme string
return {
...persistedState,
theme: persistedState.darkMode ? 'dark' : 'light',
}
}
return persistedState
},
}
),
{ name: 'SettingsStore' } // DevTools Anzeigename
)
)
Custom Storage (sessionStorage, IndexedDB, AsyncStorage)
import { StateStorage } from 'zustand/middleware'
// IndexedDB-basierter Storage (z.B. für große Datenmengen)
const idbStorage: StateStorage = {
getItem: async (name) => {
const db = await openDB('zustand-store', 1)
return db.get('store', name) ?? null
},
setItem: async (name, value) => {
const db = await openDB('zustand-store', 1)
await db.put('store', value, name)
},
removeItem: async (name) => {
const db = await openDB('zustand-store', 1)
await db.delete('store', name)
},
}
// Verwendung in persist
const useStore = create<MyState>()(
persist(storeCreator, {
name: 'my-large-store',
storage: createJSONStorage(() => idbStorage),
})
)
DevTools-Integration: Nach Installation der Redux DevTools Browser-Extension (Chrome/Firefox) siehst du alle Zustand-Aktionen mit dem Namen aus dem dritten set()-Argument. Time-Travel-Debugging, State-Export und Action-Log funktionieren out-of-the-box.
4. immer Middleware: Immutability ohne Schmerz
Tief verschachtelter State in JavaScript ist ein bekanntes Problem. Ohne immer sieht ein
einfaches Update schnell so aus: set(s => ({...s, a: {...s.a, b: {...s.a.b, c: newVal}}})).
Die immer-Middleware von Zustand löst dieses Problem elegant.
Middleware
immer: Mutierender Stil, immutable Ergebnis
Mit immer kannst du State direkt mutieren — immer erkennt die Änderungen und
produziert intern ein neues, immutables State-Objekt via Proxy-Magie.
// store/organization.ts — immer für tiefe Updates
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface Department {
id: string
name: string
members: { id: string; name: string; role: string }[]
budget: { allocated: number; spent: number }
}
interface OrgState {
departments: Record<string, Department>
addMember: (deptId: string, member: { id: string; name: string; role: string }) => void
updateBudgetSpent: (deptId: string, amount: number) => void
promoteMember: (deptId: string, memberId: string, newRole: string) => void
}
export const useOrgStore = create<OrgState>()(
immer((set) => ({
departments: {
engineering: {
id: 'engineering',
name: 'Engineering',
members: [
{ id: 'u1', name: 'Ana Müller', role: 'Senior Dev' },
],
budget: { allocated: 120000, spent: 45000 },
},
},
// ✅ Mit immer: direktes Mutieren — kein Spread-Chaos
addMember: (deptId, member) =>
set((state) => {
state.departments[deptId].members.push(member)
}),
updateBudgetSpent: (deptId, amount) =>
set((state) => {
state.departments[deptId].budget.spent += amount
}),
promoteMember: (deptId, memberId, newRole) =>
set((state) => {
const member = state.departments[deptId].members
.find((m) => m.id === memberId)
if (member) member.role = newRole
}),
}))
)
Vergleich: Mit und ohne immer
// ❌ Ohne immer — Spread-Hölle bei tiefem State
updateBudgetSpent: (deptId, amount) =>
set((state) => ({
departments: {
...state.departments,
[deptId]: {
...state.departments[deptId],
budget: {
...state.departments[deptId].budget,
spent: state.departments[deptId].budget.spent + amount,
},
},
},
})),
// ✅ Mit immer — sauber und lesbar
updateBudgetSpent: (deptId, amount) =>
set((state) => {
state.departments[deptId].budget.spent += amount
}),
Performance: immer nutzt strukturelles Sharing — nur die geänderten Teile des State-Baums werden kopiert. Unveränderliche Äste teilen sich die gleichen Objekt-Referenzen. Das ist in der Praxis oft schneller als manuelle Spread-Operatoren bei großen State-Objekten.
5. Slices Pattern: Große Stores modular aufbauen
Wenn eine App wächst, wird ein einzelner monolithischer Store schwer wartbar. Das Slices-Pattern
ist Zustand's Antwort auf Redux Toolkit's createSlice — aber ohne den Boilerplate.
Slices
Slice-First Architecture
Jeder Domain-Bereich (Auth, Cart, UI, Notifications) lebt in einem eigenen Slice.
Der Root-Store kombiniert alle Slices zu einem einzigen, reaktiven Store-Objekt.
// store/slices/authSlice.ts
import { StateCreator } from 'zustand'
export interface AuthSlice {
user: { id: string; name: string; email: string } | null
isAuthenticated: boolean
accessToken: string | null
login: (email: string, password: string) => Promise<void>
logout: () => void
refreshToken: () => Promise<void>
}
export const createAuthSlice: StateCreator<
RootState, // Voller Store-Typ (für Cross-Slice-Zugriff)
[],
[],
AuthSlice
> = (set, get) => ({
user: null,
isAuthenticated: false,
accessToken: null,
login: async (email, password) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
set({
user: data.user,
accessToken: data.accessToken,
isAuthenticated: true,
})
// Cross-Slice: NotificationSlice benachrichtigen
get().addNotification({ type: 'success', message: `Willkommen, ${data.user.name}!` })
},
logout: () => {
set({ user: null, isAuthenticated: false, accessToken: null })
get().clearCart() // Cross-Slice: Cart leeren beim Logout
},
refreshToken: async () => {
const response = await fetch('/api/auth/refresh')
const { accessToken } = await response.json()
set({ accessToken })
},
})
// store/slices/notificationSlice.ts
export interface NotificationSlice {
notifications: { id: string; type: 'info'|'success'|'error'; message: string }[]
addNotification: (n: Omit<NotificationSlice['notifications'][number], 'id'>) => void
dismissNotification: (id: string) => void
}
export const createNotificationSlice: StateCreator<RootState, [], [], NotificationSlice> =
(set) => ({
notifications: [],
addNotification: (n) => set((state) => ({
notifications: [...state.notifications, { ...n, id: crypto.randomUUID() }],
})),
dismissNotification: (id) => set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
})
// store/index.ts — Root Store: alle Slices kombinieren
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createAuthSlice, AuthSlice } from './slices/authSlice'
import { createNotificationSlice, NotificationSlice } from './slices/notificationSlice'
import { createCartSlice, CartSlice } from './slices/cartSlice'
// Root State = Union aller Slices
export type RootState = AuthSlice & NotificationSlice & CartSlice
export const useStore = create<RootState>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...a),
...createNotificationSlice(...a),
...createCartSlice(...a),
}),
{
name: 'app-root-store',
// Nur Auth persistieren, nicht Notifications
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
isAuthenticated: state.isAuthenticated,
items: state.items,
}),
}
),
{ name: 'RootStore' }
)
)
// Bequeme Slice-spezifische Hooks
export const useAuthStore = () => useStore((s) => ({
user: s.user, isAuthenticated: s.isAuthenticated, login: s.login, logout: s.logout,
}))
export const useNotifications = () => useStore((s) => ({
notifications: s.notifications,
addNotification: s.addNotification,
dismissNotification: s.dismissNotification,
}))
6. Async Actions & Vergleich zu Redux/Jotai/Recoil
Zustand hat keine eingebaute Async-Middleware wie Redux Thunk oder Redux Saga — und das ist
Absicht. Async Actions sind einfach normale async-Funktionen im Store, die
nach Abschluss set() aufrufen.
Async
Async Actions mit Loading & Error State
Das Pattern: Loading-Flag setzen → Async-Operation → State mit Ergebnis setzen →
Error-Handling. Kein Middleware-Setup nötig.
// store/products.ts — vollständiger Async Store
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface Product {
id: string
name: string
price: number
category: string
}
interface ProductsState {
products: Product[]
selectedProduct: Product | null
isLoading: boolean
error: string | null
page: number
hasMore: boolean
filters: { category: string | null; maxPrice: number | null }
fetchProducts: (reset?: boolean) => Promise<void>
fetchProduct: (id: string) => Promise<void>
setFilter: (filter: Partial<ProductsState['filters']>) => void
}
export const useProductsStore = create<ProductsState>()(
immer((set, get) => ({
products: [],
selectedProduct: null,
isLoading: false,
error: null,
page: 1,
hasMore: true,
filters: { category: null, maxPrice: null },
fetchProducts: async (reset = false) => {
const { page, filters } = get()
const currentPage = reset ? 1 : page
set((state) => {
state.isLoading = true
state.error = null
if (reset) state.products = []
})
try {
const params = new URLSearchParams({
page: currentPage.toString(),
limit: '20',
...(filters.category ? { category: filters.category } : {}),
...(filters.maxPrice ? { maxPrice: filters.maxPrice.toString() } : {}),
})
const res = await fetch(`/api/products?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { data, meta } = await res.json()
set((state) => {
state.products.push(...data)
state.page = currentPage + 1
state.hasMore = meta.hasMore
state.isLoading = false
})
} catch (err) {
set((state) => {
state.error = err instanceof Error ? err.message : 'Unbekannter Fehler'
state.isLoading = false
})
}
},
fetchProduct: async (id) => {
set((state) => { state.isLoading = true; state.error = null })
try {
const res = await fetch(`/api/products/${id}`)
const product: Product = await res.json()
set((state) => { state.selectedProduct = product; state.isLoading = false })
} catch (err) {
set((state) => {
state.error = err instanceof Error ? err.message : 'Ladefehler'
state.isLoading = false
})
}
},
setFilter: (filter) =>
set((state) => { Object.assign(state.filters, filter) }),
}))
)
Vergleich: Zustand vs. Redux Toolkit vs. Jotai vs. Recoil
Die Wahl der State-Management-Library hängt vom Projekt-Kontext ab. Hier ein fairer
Vergleich der vier populärsten Optionen im Jahr 2026:
| Kriterium |
Zustand |
Redux Toolkit |
Jotai |
Recoil |
| Bundle-Größe |
~1 kB |
~15 kB |
~3 kB |
~14 kB |
| Boilerplate |
Minimal |
Mittel |
Sehr gering |
Mittel |
| TypeScript |
Nativ |
Nativ |
Nativ |
Gut |
| DevTools |
Redux DevTools |
Redux DevTools |
Jotai DevTools |
Recoil DevTools |
| Async Actions |
Nativ (async fn) |
RTK Query / Thunk |
Atom Effects |
Atom Effects |
| Middleware |
persist, immer, devtools |
Umfangreich |
Begrenzt |
Begrenzt |
| React 19 Compat. |
Vollständig |
Vollständig |
Vollständig |
Eingeschränkt |
| Lernkurve |
Sehr flach |
Mittel |
Flach |
Mittel |
| Claude Code Support |
Exzellent |
Sehr gut |
Gut |
Gut |
| Serverless/Edge |
Ja |
Eingeschränkt |
Ja |
Nein |
Wann welche Library?
Entscheidungshelfer
Empfehlungen nach Anwendungsfall
- Zustand: 90% aller React-Apps — einfach, performant, typsicher
- Redux Toolkit: Enterprise-Apps mit komplexem Async-Flow, großes Team, strikte Konventionen
- Jotai: Granulare, atomare State-Updates; Server Components mit React 19; Micro-Frontends
- Recoil: Abhängigkeitsgraphen zwischen Atoms; wird 2026 weniger aktiv gepflegt
Claude Code Best Practice: Starte immer mit Zustand. Wenn du nach 6 Monaten merkst, dass du RTK Query oder komplexe Middleware-Ketten wirklich brauchst, migriere dann. Premature optimization gilt auch für State-Management-Libraries.
React
TypeScript
Zustand
State Management
Claude Code
Redux
immer
Middleware
Performance