Claude Code Zustand: React State Management mit Zustand, Jotai und modernen State-Patterns

State Management ist einer der wenigen Bereiche in der React-Entwicklung, bei dem die Wahl der Bibliothek echte architektonische Konsequenzen hat. Redux war jahrelang Standard — und hat viele Teams mit Boilerplate-Last und undurchsichtigen Datenflüssen frustriert. Zustand und Jotai haben das Spielfeld verändert: weniger Setup, mehr Klarheit, flexiblere Patterns. Und Claude Code hat verändert, wie schnell man mit diesen Bibliotheken produktiv wird.

Dieser Artikel zeigt konkret, wie du Claude Code Zustand-Stores aufbaust, strukturierst und mit Middleware, Persist und DevTools erweiterst — inklusive der modernsten Patterns für Slices, selektive Subscriptions und atomares State-Design mit Jotai.

Claude Code Mastery — React, Agents, Hooks auf Deutsch

State Management ist nur ein Teil: der Kurs zeigt den vollständigen Claude-Code-Workflow für produktive Entwicklung. Einmalig bezahlt, kein Abo.

Zum Kurs — Jetzt starten → Einmalzahlung · Kein Abo · 14 Tage Rückgaberecht

1. Warum Zustand die bessere Wahl für die meisten React-Apps ist

Zustand (das deutsche Wort für "state" — kein Zufall) ist eine minimalistische State-Management-Bibliothek von Poimandres, den Machern von react-spring und react-three-fiber. Die Grundphilosophie: kein Provider, kein Context-Wrapper, kein Boilerplate. Ein Store ist eine einfache Funktion, die State und Actions vereint.

Der Vergleich mit Redux zeigt, worum es geht: für eine einfache Counter-Logik braucht Redux mindestens vier Dateien (Store, Reducer, Actions, Selectors). Zustand braucht eine. Das ist kein Komfort-Argument — das ist ein Wartbarkeitsargument. Weniger Code bedeutet weniger Stellen, an denen etwas falsch laufen kann.

Claude Code versteht diese Bibliothek tiefgehend. Wenn du beschreibst, was dein State können soll, generiert Claude Code nicht nur den Store — es wählt automatisch die richtigen Patterns: selektive Subscriptions für Performance, Middleware für Cross-Cutting-Concerns, Persist für lokale Datenhaltung. Das ist der Unterschied zwischen "KI die Code tippt" und "KI die versteht, was guter Code ist".

Setup in 30 Sekunden: npm install zustand — fertig. Kein Provider, kein Wrapper, kein Konfigurationsaufwand. Claude Code kann direkt mit dem Store-Design anfangen.

2. Einen Zustand Store mit Claude Code erstellen

Der schnellste Weg: Beschreibe deinen State in natürlicher Sprache, Claude Code generiert den typisierten Store.

claude "Erstelle einen Zustand Store für einen E-Commerce Warenkorb mit TypeScript.
Der Store soll Produkte hinzufügen, entfernen und die Menge ändern können.
Außerdem soll der Gesamtpreis automatisch berechnet werden."

Was Claude Code daraus macht:

import { create } from 'zustand'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: string) => void
  updateQuantity: (id: string, quantity: number) => void
  clearCart: () => void
  totalPrice: () => number
  totalItems: () => number
}

export const useCartStore = create<CartState>()((set, get) => ({
  items: [],

  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id)
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        }
      }
      return { items: [...state.items, { ...item, 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: [] }),

  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),

  totalItems: () =>
    get().items.reduce((sum, item) => sum + item.quantity, 0),
}))

Beachte die Details: Claude Code generiert nicht nur den Store, sondern wählt get() für berechnete Werte statt set() — eine wichtige Performance-Entscheidung. Berechnete Werte als Funktionen bedeuten, dass sie bei jedem Aufruf frisch berechnet werden, statt als gespeicherter State unnötige Re-Renders auszulösen.

3. Slices: Großen State strukturieren

Wenn ein Store wächst, braucht er Struktur. Das Slice-Pattern trennt Concerns sauber, ohne auf separate Stores ausweichen zu müssen. Claude Code kennt dieses Pattern und setzt es auf Wunsch automatisch um.

claude "Mein Zustand Store wird zu groß. Refaktoriere ihn in Slices:
einen für User-State, einen für Cart-State und einen für UI-State.
Alle sollen im gleichen Store zusammengefasst sein."
import { create, StateCreator } from 'zustand'

// --- User Slice ---
interface UserSlice {
  user: { id: string; name: string; email: string } | null
  isAuthenticated: boolean
  login: (userData: { id: string; name: string; email: string }) => void
  logout: () => void
}

const createUserSlice: StateCreator<
  UserSlice & CartSlice & UISlice,
  [],
  [],
  UserSlice
> = (set) => ({
  user: null,
  isAuthenticated: false,
  login: (userData) => set({ user: userData, isAuthenticated: true }),
  logout: () => set({ user: null, isAuthenticated: false }),
})

// --- Cart Slice ---
interface CartSlice {
  cartItems: CartItem[]
  addToCart: (item: CartItem) => void
  removeFromCart: (id: string) => void
}

const createCartSlice: StateCreator<
  UserSlice & CartSlice & UISlice,
  [],
  [],
  CartSlice
> = (set) => ({
  cartItems: [],
  addToCart: (item) =>
    set((state) => ({ cartItems: [...state.cartItems, item] })),
  removeFromCart: (id) =>
    set((state) => ({
      cartItems: state.cartItems.filter((i) => i.id !== id),
    })),
})

// --- UI Slice ---
interface UISlice {
  isCartOpen: boolean
  isMobileMenuOpen: boolean
  toggleCart: () => void
  toggleMobileMenu: () => void
}

const createUISlice: StateCreator<
  UserSlice & CartSlice & UISlice,
  [],
  [],
  UISlice
> = (set) => ({
  isCartOpen: false,
  isMobileMenuOpen: false,
  toggleCart: () => set((state) => ({ isCartOpen: !state.isCartOpen })),
  toggleMobileMenu: () =>
    set((state) => ({ isMobileMenuOpen: !state.isMobileMenuOpen })),
})

// --- Combined Store ---
export const useStore = create<UserSlice & CartSlice & UISlice>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
  ...createUISlice(...a),
}))

Das Slice-Pattern skaliert mit der App-Größe, ohne den Store unhandlich zu machen. Claude Code löst dabei automatisch das TypeScript-Problem des kombinierten State-Typs — der StateCreator-Typ mit dem vollständigen kombinierten Type als erstem Generic ist die korrekte Lösung, aber ohne KI-Unterstützung kostet das regelmäßig eine halbe Stunde Dokumentation lesen.

4. Middleware: Persist, DevTools und Immer

Zustand hat ein durchdachtes Middleware-System. Die drei wichtigsten Middlewares — persist, devtools und immer — lassen sich kombinieren und Claude Code weiß genau, in welcher Reihenfolge das passieren muss.

Persist: State im LocalStorage speichern

claude "Füge Persist-Middleware zu meinem Cart Store hinzu.
Nur cartItems soll gespeichert werden, nicht der UI-State.
Storage soll localStorage sein mit einem versionsbasierten Migration-Schema."
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      version: 1,
      addItem: (item) => set((state) => ({ /* ... */ })),
      // weitere Actions...
    }),
    {
      name: 'cart-storage',
      storage: createJSONStorage(() => localStorage),
      // Nur bestimmte Felder persistieren
      partialize: (state) => ({ items: state.items }),
      // Migration bei Version-Wechsel
      version: 1,
      migrate: (persistedState: unknown, version: number) => {
        if (version === 0) {
          // Migration von v0 zu v1: Felder umbenennen
          const old = persistedState as { products: CartItem[] }
          return { items: old.products ?? [] }
        }
        return persistedState as CartState
      },
    }
  )
)

DevTools: Redux DevTools Browser-Extension nutzen

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

export const useCartStore = create<CartState>()(
  devtools(
    persist(
      (set) => ({
        items: [],
        addItem: (item) =>
          set(
            (state) => ({ items: [...state.items, item] }),
            false,           // replace = false: merge statt ersetzen
            'cart/addItem'   // Action-Name in DevTools
          ),
      }),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
)

Middleware-Reihenfolge ist kritisch: devtools muss immer außen sein, persist kommt darunter, immer kommt innen. Claude Code hält diese Reihenfolge automatisch ein — bei manueller Konfiguration ist das eine häufige Fehlerquelle.

Immer: Immutable Updates ohne Spread-Hölle

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

export const useCartStore = create<CartState>()(
  immer((set) => ({
    items: [],
    // Mit Immer: direkte Mutationen statt Spread-Syntax
    addItem: (item) =>
      set((state) => {
        const existing = state.items.find((i) => i.id === item.id)
        if (existing) {
          existing.quantity += 1  // Direkte Mutation dank Immer
        } else {
          state.items.push({ ...item, quantity: 1 })
        }
      }),
    updateQuantity: (id, quantity) =>
      set((state) => {
        const item = state.items.find((i) => i.id === id)
        if (item) item.quantity = quantity
      }),
  }))
)

Immer-Middleware macht besonders bei tief verschachteltem State den Unterschied. Claude Code erkennt, wann Immer sinnvoll ist — bei flachem State mit einfachen Updates ist es oft überflüssig, bei komplexen Nested-Strukturen ist es eine klare Verbesserung.

5. Selektive Subscriptions: Performance richtig machen

Der häufigste Performance-Fehler mit Zustand: den gesamten Store subscriben, wenn nur ein einziges Feld benötigt wird. Das führt zu unnötigen Re-Renders bei jeder State-Änderung, auch wenn das relevante Feld unverändert ist.

// FALSCH: Kompletten Store subscriben
const { items, addItem, removeItem, totalPrice } = useCartStore()

// RICHTIG: Nur das tatsächlich benötigte Feld
const items = useCartStore((state) => state.items)
const totalPrice = useCartStore((state) => state.totalPrice())
const addItem = useCartStore((state) => state.addItem)

Claude Code generiert diese selektiven Subscriptions automatisch, wenn du Komponenten bittest, Zustand-State zu verwenden. Es erkennt, welche Felder die Komponente tatsächlich braucht, und generiert nur die passenden Selektoren.

Shallow-Vergleich für Objekte

import { useShallow } from 'zustand/react/shallow'

// Mehrere Felder ohne kompletten Re-Render bei Änderung anderer Felder
const { items, totalItems } = useCartStore(
  useShallow((state) => ({
    items: state.items,
    totalItems: state.totalItems(),
  }))
)

useShallow führt einen oberflächlichen Vergleich durch statt eines Referenzvergleichs. Ohne es würde jeder State-Update eine neue Objekt-Referenz erzeugen und alle Subscriber re-rendern, auch wenn die Werte gleich geblieben sind.

claude "Überprüfe meine ProductList-Komponente auf unnötige Re-Renders
durch zu breite Zustand-Subscriptions und optimiere sie."

Claude Code liest die Komponente, identifiziert, welche State-Felder tatsächlich gerendert werden, und schreibt die Subscriptions auf das nötige Minimum um. Ein Schritt der bei manueller Analyse pro Komponente 15–30 Minuten kostet, mit Claude Code unter einer Minute liegt.

6. Jotai: Atomares State-Management

Jotai verfolgt einen anderen Ansatz als Zustand: statt eines zentralen Stores gibt es atomare State-Einheiten, die sich frei kombinieren lassen. Das ist besonders stark für State, der von vielen unabhängigen Komponenten geteilt wird, ohne globalen Store-Overhead.

npm install jotai
claude "Erstelle Jotai Atoms für eine Filterfunktion in einem Produktkatalog.
Es soll Preis-Filter, Kategorie-Filter und Suchtext geben.
Ein derived Atom soll die gefilterten Produkte zurückgeben."
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// Primitive Atoms
export const searchTextAtom = atom('')
export const selectedCategoryAtom = atom<string | null>(null)
export const priceRangeAtom = atom<[number, number]>([0, 1000])

// Alle Produkte (normalerweise aus einem API-Fetch)
export const productsAtom = atom<Product[]>([])

// Derived Atom: berechnet gefilterte Produkte
export const filteredProductsAtom = atom((get) => {
  const products = get(productsAtom)
  const searchText = get(searchTextAtom).toLowerCase()
  const category = get(selectedCategoryAtom)
  const [minPrice, maxPrice] = get(priceRangeAtom)

  return products.filter((product) => {
    const matchesSearch =
      product.name.toLowerCase().includes(searchText) ||
      product.description.toLowerCase().includes(searchText)

    const matchesCategory = category === null || product.category === category

    const matchesPrice =
      product.price >= minPrice && product.price <= maxPrice

    return matchesSearch && matchesCategory && matchesPrice
  })
})

// Active Filter Count Atom
export const activeFilterCountAtom = atom((get) => {
  let count = 0
  if (get(searchTextAtom)) count++
  if (get(selectedCategoryAtom)) count++
  const [min, max] = get(priceRangeAtom)
  if (min > 0 || max < 1000) count++
  return count
})

Atoms in Komponenten verwenden

// Suchfeld: liest und schreibt
function SearchInput() {
  const [searchText, setSearchText] = useAtom(searchTextAtom)
  return (
    <input
      value={searchText}
      onChange={(e) => setSearchText(e.target.value)}
      placeholder="Suchen..."
    />
  )
}

// Produktliste: nur lesen (kein Re-Render bei Suchtext-Änderung)
function ProductList() {
  const filteredProducts = useAtomValue(filteredProductsAtom)
  return (
    <ul>
      {filteredProducts.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

// Filter zurücksetzen: nur schreiben (kein Re-Render)
function ResetButton() {
  const setSearch = useSetAtom(searchTextAtom)
  const setCategory = useSetAtom(selectedCategoryAtom)
  const setPriceRange = useSetAtom(priceRangeAtom)

  const resetAll = () => {
    setSearch('')
    setCategory(null)
    setPriceRange([0, 1000])
  }

  return <button onClick={resetAll}>Filter zurücksetzen</button>
}

Der entscheidende Performance-Vorteil: useAtomValue und useSetAtom erlauben granulare Subscriptions ohne jeden Overhead. Eine Komponente die nur schreibt (useSetAtom), re-rendert niemals bei State-Änderungen. Eine Komponente die nur liest (useAtomValue), re-rendert nur wenn genau ihr Atom sich ändert.

Async Atoms: Daten von APIs laden

claude "Erstelle einen Jotai Atom der Produkte von einer API lädt,
mit Loading- und Error-State, und automatisch neu lädt wenn sich
der selectedCategoryAtom ändert."
import { atom } from 'jotai'
import { loadable } from 'jotai/utils'

// Async Atom: lädt automatisch neu bei Category-Änderung
const productsQueryAtom = atom(async (get) => {
  const category = get(selectedCategoryAtom)
  const url = category
    ? `/api/products?category=${category}`
    : '/api/products'

  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`API Error: ${response.status}`)
  }
  return response.json() as Promise<Product[]>
})

// loadable: gibt { state, data, error } zurück statt zu werfen
export const productsLoadableAtom = loadable(productsQueryAtom)

// In der Komponente:
function ProductsWithState() {
  const productsLoadable = useAtomValue(productsLoadableAtom)

  if (productsLoadable.state === 'loading') return <Spinner />
  if (productsLoadable.state === 'hasError') {
    return <ErrorMessage error={productsLoadable.error} />
  }
  return <ProductList products={productsLoadable.data} />
}

7. Wann Zustand, wann Jotai, wann Context?

Die Entscheidung ist weniger dogmatisch als oft dargestellt. Claude Code kann auf Wunsch analysieren, welche Bibliothek für einen konkreten Anwendungsfall besser passt.

claude "Ich habe Authentication-State, Warenkorb, UI-State (Modals, Sidebars)
und gefilterte Produktlisten. Welche State-Management-Strategie empfiehlst du
für meine React-App und warum?"

Die Antwort, die Claude Code in diesem Fall geben würde — und die meistens korrekt ist:

Die häufigste Fehlinvestition: globalen State-Manager für lokalen State. Wenn nur eine Komponente und ihre direkten Kinder den State brauchen, ist useState die richtige Wahl — kein Zustand, kein Jotai, kein Context.

8. Testing: State Management-Tests mit Claude Code

State-Tests sind oft vernachlässigt, weil das Setup aufwändig ist. Claude Code generiert komplette Test-Setups für Zustand- und Jotai-Stores inklusive des notwendigen Mocking-Boilerplates.

claude "Schreibe Vitest-Tests für meinen Cart Store.
Teste: addItem, removeItem, updateQuantity, clearCart und totalPrice.
Inklusive Edge Cases: negativer Quantity, doppeltes addItem für gleiche ID."
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from './cartStore'

// Store vor jedem Test zurücksetzen
beforeEach(() => {
  useCartStore.setState({ items: [] })
})

describe('CartStore', () => {
  const mockItem = { id: '1', name: 'Test Product', price: 29.99 }

  it('fügt ein neues Item hinzu', () => {
    useCartStore.getState().addItem(mockItem)
    const { items } = useCartStore.getState()
    expect(items).toHaveLength(1)
    expect(items[0]).toMatchObject({ ...mockItem, quantity: 1 })
  })

  it('erhöht Quantity bei doppeltem addItem', () => {
    useCartStore.getState().addItem(mockItem)
    useCartStore.getState().addItem(mockItem)
    const { items } = useCartStore.getState()
    expect(items).toHaveLength(1)
    expect(items[0].quantity).toBe(2)
  })

  it('entfernt ein Item', () => {
    useCartStore.getState().addItem(mockItem)
    useCartStore.getState().removeItem('1')
    expect(useCartStore.getState().items).toHaveLength(0)
  })

  it('berechnet totalPrice korrekt', () => {
    useCartStore.getState().addItem(mockItem)
    useCartStore.getState().addItem({ id: '2', name: 'Other', price: 10 })
    expect(useCartStore.getState().totalPrice()).toBeCloseTo(39.99)
  })

  it('entfernt Item bei Quantity 0', () => {
    useCartStore.getState().addItem(mockItem)
    useCartStore.getState().updateQuantity('1', 0)
    expect(useCartStore.getState().items).toHaveLength(0)
  })
})

Wichtig: useCartStore.setState() und useCartStore.getState() funktionieren in Tests ohne React-Provider. Das ist einer der größten Vorteile von Zustand gegenüber Context-basiertem State — keine Wrapper-Komponenten nötig, kein Render-Setup für einfache Unit-Tests.

9. Realer Einsatz: Migration von Redux auf Zustand

Viele Teams stehen vor der Frage: den bestehenden Redux-Store beibehalten oder auf Zustand migrieren? Claude Code kann diese Migration strukturiert durchführen und ist dabei ehrlich über Risiken.

claude "Analysiere meinen Redux authSlice und schreibe ihn als Zustand Store um.
Behalte die gleiche öffentliche API damit ich die Migration inkrementell
durchführen kann ohne alle Komponenten gleichzeitig anpassen zu müssen."

Claude Code liest den Redux-Slice, identifiziert Actions, Selectors und Reducer, und schreibt daraus einen Zustand Store mit kompatibler API. Besonders wertvoll: es generiert einen Adapter-Layer der während der Migration-Phase alte Redux-Selectors auf den neuen Zustand Store umleitet — so kann man eine große Codebase schrittweise migrieren, ohne einen Big-Bang-Rewrite zu riskieren.

Das ist der Unterschied zwischen "KI die schnell Code schreibt" und "KI die Migrationsstrategien versteht". Claude Code kennt das Risiko eines Big-Bang-Rewrites und empfiehlt automatisch inkrementelle Ansätze.

10. State Devtools und Debugging-Workflow

Mit aktivierten Redux DevTools (Chrome/Firefox Extension) bekommst du für jeden Zustand-Action einen vollständigen State-Snapshot. Claude Code kann diesen Workflow gezielt nutzen:

claude "In meiner App gibt es einen State-Bug: nach dem Login wird der
Cart-State nicht korrekt initialisiert. Hier ist der DevTools-Export
der letzten 5 Actions: [JSON einfügen]. Was ist die Ursache?"

Claude Code analysiert den Action-Verlauf, sieht wo der State-Übergang schief läuft, und findet die Ursache — in diesem häufigen Fall oft ein Race Condition zwischen dem Login-Action und dem Persist-Rehydrierungsschritt. Ein Bug der ohne den DevTools-Export schwer zu reproduzieren ist, aber mit dem Export in zwei Minuten gefunden wird.

Zwei verwandte Artikel aus der Serie:


Claude Code Mastery — State Management, Agents und Hooks auf Deutsch

Zustand und Jotai sind ein Teil des Kurses. Du lernst außerdem: MCP-Server, Agents, Multi-Agent-Workflows, Hooks und den kompletten Entwicklungs-Workflow mit Claude Code. Einmalig bezahlt, kein Abo.

Jetzt starten → Einmalzahlung · Kein Abo · 14 Tage Rückgaberecht

Kurs · Claude Code Mastery

Von State Management zum produktiven AI-Agenten

Zustand. Jotai. Agents. MCP. Hooks. Multi-Agent-Workflows. Alles auf Deutsch, einmalig bezahlt — kein Abo, keine Plattformabhängigkeit.

Jetzt einsteigen → Kursübersicht ansehen →

Einmalzahlung · Kein Abo · 14 Tage Rückgaberecht