Zustand State Management: Patterns for Complex Apps

Master Zustand state management for React apps. Covers slices, middleware, persistence, devtools, TypeScript patterns, and integration with TanStack Query for complex application state.

E
ECOSIRE Research and Development Team
|19. März 202610 Min. Lesezeit2.2k Wörter|

Zustandsverwaltung: Muster für komplexe Apps

Die integrierten Zustandsprimitive von React (useState, useReducer, useContext) funktionieren gut für lokale und gemeinsame UI-Zustände, stoßen jedoch bei großem Maßstab auf Reibung: Kontextanbieter rendern den gesamten Baum bei jeder Zustandsänderung neu, useReducer erfordert eine ausführliche Aktions-Boilerplate und das Bohren von Requisiten ist nicht mehr zu bewältigen. Redux löst dieses Problem, allerdings auf Kosten eines erheblichen Aufwands an Boilerplate und mentalem Aufwand.

Zustand nimmt einen perfekten Mittelweg ein: 2,9 KB, keine Anbieter, kein Boilerplate, TypeScript-first und kompatibel mit React DevTools. Es zeichnet sich durch den clientseitigen Anwendungsstatus aus – Einkaufswagen, Seitenleistenstatus, mehrstufige Formulardaten, UI-Einstellungen – während TanStack Query den Serverstatus verwaltet. In diesem Handbuch werden Zustand v5-Muster für Produktionsanwendungen behandelt.

Wichtige Erkenntnisse

– Zustand ist für den Nur-Client-Zustand; Verwenden Sie TanStack Query für den Serverstatus – überschneiden Sie sich nicht in ihren Zuständigkeiten

  • Kein Provider-Wrapper erforderlich – der Store ist ein Singleton auf Modulebene, auf den überall zugegriffen werden kann – Verwenden Sie das Slices-Muster für große Geschäfte: Fassen Sie mehrere Slice-Ersteller in einem create()-Aufruf zusammen
  • immer-Middleware ermöglicht direkte Mutationssyntax innerhalb von Aktionen (keine Spread-Operatoren)
  • persist-Middleware mit localStorage oder sessionStorage für sitzungsübergreifenden Status
  • Die devtools-Middleware lässt sich in die Redux DevTools-Erweiterung für das Debuggen von Zeitreisen integrieren – Selektoren mit useShallow verhindern unnötiges erneutes Rendern von flach gleichen Objekten
  • Speichern Sie Serverdaten niemals im Zustand – die Ungültigmachung wird zu Ihrem Problem

Basic Store

// src/lib/stores/ui.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface UiState {
  // State
  sidebarOpen: boolean;
  theme: 'light' | 'dark' | 'system';
  commandPaletteOpen: boolean;
  activeModal: string | null;

  // Actions
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  setTheme: (theme: UiState['theme']) => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
  toggleCommandPalette: () => void;
}

export const useUiStore = create<UiState>()(
  devtools(
    (set) => ({
      sidebarOpen: true,
      theme: 'system',
      commandPaletteOpen: false,
      activeModal: null,

      toggleSidebar: () =>
        set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar'),

      setSidebarOpen: (open) =>
        set({ sidebarOpen: open }, false, 'setSidebarOpen'),

      setTheme: (theme) =>
        set({ theme }, false, 'setTheme'),

      openModal: (modalId) =>
        set({ activeModal: modalId }, false, 'openModal'),

      closeModal: () =>
        set({ activeModal: null }, false, 'closeModal'),

      toggleCommandPalette: () =>
        set(
          (state) => ({ commandPaletteOpen: !state.commandPaletteOpen }),
          false,
          'toggleCommandPalette'
        ),
    }),
    { name: 'ui-store' }
  )
);
// Usage in components — select only what you need
function Sidebar() {
  const isOpen = useUiStore((state) => state.sidebarOpen);
  const toggle = useUiStore((state) => state.toggleSidebar);

  return (
    <aside className={cn('sidebar', isOpen && 'sidebar--open')}>
      <button onClick={toggle}>Toggle</button>
    </aside>
  );
}

Cart Store mit Persistenz

// src/lib/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface CartItem {
  id: string;
  productId: string;
  name: string;
  price: number;
  quantity: number;
  licenseType: 'one-time' | 'subscription';
}

interface CartState {
  items: CartItem[];
  couponCode: string | null;
  couponDiscount: number;

  // Computed (derived state — use selectors, not stored values)
  // subtotal, total, itemCount — computed in selectors

  // Actions
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  applyCoupon: (code: string, discount: number) => void;
  removeCoupon: () => void;
  clearCart: () => void;
}

export const useCart = create<CartState>()(
  devtools(
    persist(
      immer((set) => ({
        items: [],
        couponCode: null,
        couponDiscount: 0,

        addItem: (newItem) =>
          set((state) => {
            const existing = state.items.find((i) => i.productId === newItem.productId);
            if (existing) {
              existing.quantity += 1; // Immer allows direct mutation
            } else {
              state.items.push({ ...newItem, quantity: 1 });
            }
          }),

        removeItem: (productId) =>
          set((state) => {
            state.items = state.items.filter((i) => i.productId !== productId);
          }),

        updateQuantity: (productId, quantity) =>
          set((state) => {
            if (quantity <= 0) {
              state.items = state.items.filter((i) => i.productId !== productId);
            } else {
              const item = state.items.find((i) => i.productId === productId);
              if (item) item.quantity = quantity;
            }
          }),

        applyCoupon: (code, discount) =>
          set((state) => {
            state.couponCode = code;
            state.couponDiscount = discount;
          }),

        removeCoupon: () =>
          set((state) => {
            state.couponCode = null;
            state.couponDiscount = 0;
          }),

        clearCart: () =>
          set((state) => {
            state.items = [];
            state.couponCode = null;
            state.couponDiscount = 0;
          }),
      })),
      {
        name: 'ecosire-cart',
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({
          // Only persist items and coupon, not derived state
          items: state.items,
          couponCode: state.couponCode,
          couponDiscount: state.couponDiscount,
        }),
      }
    ),
    { name: 'cart-store' }
  )
);

// Selectors — computed from state, not stored
export const selectSubtotal = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

export const selectTotal = (state: CartState) => {
  const subtotal = selectSubtotal(state);
  return subtotal * (1 - state.couponDiscount);
};

export const selectItemCount = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);

Schnittmuster für große Geschäfte

Bei komplexen Geschäften in Slices aufteilen und Folgendes zusammenstellen:

// src/lib/stores/slices/filters.slice.ts
import type { StateCreator } from 'zustand';

export interface FiltersSlice {
  search: string;
  status: string;
  page: number;
  sortField: string;
  sortDirection: 'asc' | 'desc';

  setSearch: (search: string) => void;
  setStatus: (status: string) => void;
  setPage: (page: number) => void;
  setSort: (field: string, direction: 'asc' | 'desc') => void;
  resetFilters: () => void;
}

const initialFilters = {
  search: '',
  status: 'all',
  page: 1,
  sortField: 'createdAt',
  sortDirection: 'desc' as const,
};

export const createFiltersSlice: StateCreator<FiltersSlice> = (set) => ({
  ...initialFilters,

  setSearch: (search) => set({ search, page: 1 }), // Reset page on search
  setStatus: (status) => set({ status, page: 1 }),
  setPage: (page) => set({ page }),
  setSort: (field, direction) => set({ sortField: field, sortDirection: direction }),
  resetFilters: () => set(initialFilters),
});
// src/lib/stores/slices/selection.slice.ts
import type { StateCreator } from 'zustand';

export interface SelectionSlice {
  selectedIds: Set<string>;
  selectAll: boolean;

  toggleSelection: (id: string) => void;
  selectAllItems: (ids: string[]) => void;
  clearSelection: () => void;
}

export const createSelectionSlice: StateCreator<SelectionSlice> = (set) => ({
  selectedIds: new Set(),
  selectAll: false,

  toggleSelection: (id) =>
    set((state) => {
      const next = new Set(state.selectedIds);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return { selectedIds: next, selectAll: false };
    }),

  selectAllItems: (ids) =>
    set({ selectedIds: new Set(ids), selectAll: true }),

  clearSelection: () =>
    set({ selectedIds: new Set(), selectAll: false }),
});
// src/lib/stores/contacts-table.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createFiltersSlice, type FiltersSlice } from './slices/filters.slice';
import { createSelectionSlice, type SelectionSlice } from './slices/selection.slice';

type ContactsTableStore = FiltersSlice & SelectionSlice;

export const useContactsTableStore = create<ContactsTableStore>()(
  devtools(
    (...args) => ({
      ...createFiltersSlice(...args),
      ...createSelectionSlice(...args),
    }),
    { name: 'contacts-table-store' }
  )
);

Unnötiges erneutes Rendern verhindern

Zustand rendert die Komponente neu, wenn sich der ausgewählte Wert ändert (durch Referenz für Objekte).

// PROBLEM: new object created on every render → infinite re-renders
const { search, page } = useContactsTableStore((state) => ({
  search: state.search,
  page: state.page,
}));

// SOLUTION 1: Select primitives individually
const search = useContactsTableStore((state) => state.search);
const page   = useContactsTableStore((state) => state.page);

// SOLUTION 2: Use useShallow for object selections
import { useShallow } from 'zustand/react/shallow';

const { search, page } = useContactsTableStore(
  useShallow((state) => ({ search: state.search, page: state.page }))
);

// SOLUTION 3: Select an action (actions never change reference)
const setSearch = useContactsTableStore((state) => state.setSearch);

Zustand + TanStack-Abfrage: Trennung löschen

Die Schlüsseldisziplin: Bedingung für den Client-Status, TanStack-Abfrage für den Server-Status – überlappen sich nie.

// WRONG: storing server data in Zustand
const useContactsStore = create(() => ({
  contacts: [],          // Server data in Zustand — invalidation nightmare
  fetchContacts: async () => { /* fetch and set */ },
}));

// CORRECT: TanStack Query for server data, Zustand for UI state
// TanStack Query
const { data: contacts } = useContacts({ page, limit, search });

// Zustand — only UI-specific state
const { search, page, setSearch, setPage } = useContactsTableStore();

Der Abfrageschlüssel reagiert auf Zustandsänderungen, sodass das Filtern/Sortieren einfach funktioniert:

function ContactsPage() {
  // UI state from Zustand
  const search = useContactsTableStore((s) => s.search);
  const page   = useContactsTableStore((s) => s.page);
  const status = useContactsTableStore((s) => s.status);

  // Server state from TanStack Query, driven by Zustand state
  const { data, isLoading } = useContacts({ search, page, status, limit: 20 });

  return <ContactsTable data={data?.data ?? []} loading={isLoading} />;
}

Mehrstufiger Formularspeicher

// src/lib/stores/onboarding.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface OnboardingData {
  company: { name: string; size: string; industry: string };
  contact: { name: string; email: string; phone: string };
  requirements: { erp: string; modules: string[]; budget: string };
}

interface OnboardingStore {
  currentStep: number;
  completed: Set<number>;
  data: Partial<OnboardingData>;

  setStep: (step: number) => void;
  updateStepData: <K extends keyof OnboardingData>(
    step: K,
    data: OnboardingData[K]
  ) => void;
  markStepComplete: (step: number) => void;
  reset: () => void;
}

export const useOnboardingStore = create<OnboardingStore>()(
  persist(
    (set) => ({
      currentStep: 0,
      completed: new Set(),
      data: {},

      setStep: (step) => set({ currentStep: step }),

      updateStepData: (key, stepData) =>
        set((state) => ({
          data: { ...state.data, [key]: stepData },
        })),

      markStepComplete: (step) =>
        set((state) => ({
          completed: new Set([...state.completed, step]),
        })),

      reset: () => set({ currentStep: 0, completed: new Set(), data: {} }),
    }),
    {
      name: 'onboarding-progress',
      storage: createJSONStorage(() => sessionStorage),
      // Serialize Set properly
      partialize: (state) => ({
        ...state,
        completed: Array.from(state.completed),
      }),
      merge: (persisted: any, current) => ({
        ...current,
        ...persisted,
        completed: new Set(persisted.completed ?? []),
      }),
    }
  )
);

Zustandsspeicher testen

// src/lib/stores/__tests__/cart.store.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCart, selectSubtotal, selectTotal } from '../cart';

describe('Cart Store', () => {
  beforeEach(() => {
    // Reset store state between tests
    useCart.setState({
      items: [],
      couponCode: null,
      couponDiscount: 0,
    });
  });

  it('adds items to the cart', () => {
    const { addItem } = useCart.getState();
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });

    const { items } = useCart.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(1);
  });

  it('increments quantity when same product added again', () => {
    const { addItem } = useCart.getState();
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
    addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });

    const { items } = useCart.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(2);
  });

  it('calculates subtotal correctly', () => {
    useCart.setState({
      items: [
        { id: '1', productId: 'p1', name: 'A', price: 100, quantity: 2, licenseType: 'one-time' },
        { id: '2', productId: 'p2', name: 'B', price: 50,  quantity: 1, licenseType: 'one-time' },
      ],
    });

    expect(selectSubtotal(useCart.getState())).toBe(250);
  });

  it('applies coupon discount', () => {
    useCart.setState({
      items: [{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 1, licenseType: 'one-time' }],
      couponCode: 'SAVE20',
      couponDiscount: 0.2,
    });

    expect(selectTotal(useCart.getState())).toBe(80);
  });
});

Häufig gestellte Fragen

Benötige ich weiterhin die Kontext-API mit Zustand?

Selten. Der Singleton auf Modulebene von Zustand ersetzt die meisten Anwendungsfälle der Kontext-API. Der verbleibende Hauptanwendungsfall für Context ist die Abhängigkeitsinjektion – die Übergabe einer bestimmten Store-Instanz an einen Unterbaum (nützlich zum Testen oder wenn Sie mehrere unabhängige Store-Instanzen benötigen). Zu diesem Zweck stellt Zustand createStore() + useStore() bereit, die mit einem React-Kontext für die Store-Instanz arbeiten.

Wie schneidet Zustand im Vergleich zum Redux Toolkit ab?

Redux Toolkit verfügt über mehr Struktur (Slices, Reduzierer, Aktionen) und eine bessere DevTools-Integration für komplexes Zeitreise-Debugging. Zustand hat weniger Boilerplate, ein kleineres Paket (2,9 KB gegenüber ~30 KB für Redux + RTK) und erfordert keinen Anbieter. Für die meisten Anwendungen ist Zustand einfacher und ausreichend. Wählen Sie Redux Toolkit, wenn Sie ein großes Team haben, das von erzwungenen Mustern profitiert oder erweiterte DevTools-Funktionen benötigt.

Wie verwende ich Zustand mit Next.js App Router SSR?

Zustandsspeicher sind Singletons auf Modulebene – sicher für Clientkomponenten, können aber nicht in Serverkomponenten verwendet werden (keine Browser-APIs, kein Fenster). Initialisieren Sie Speicher nur in 'use client'-Komponenten. Für die SSR-Hydratisierung der Anfangsdaten verwenden Sie HydrationBoundary von TanStack Query für Serverdaten. Für einen Nur-Client-Status (Warenkorb, UI-Einstellungen), der in der gesamten Navigation bestehen bleiben muss, verwenden Sie die Middleware persist mit localStorage.

Was ist die immer-Middleware und wann sollte ich sie verwenden?

Die Middleware immer umschließt die Funktion set von Zustand mit Immer, sodass Sie mutierenden Code in Aktionen (state.items.push(item)) schreiben können, der unter der Haube tatsächlich unveränderlich ist. Verwenden Sie es, wenn Sie verschachtelte Statusaktualisierungen haben, die mit der Spread-Syntax umständlich sind, z. B. das Aktualisieren einer tief verschachtelten Eigenschaft oder das Arbeiten mit Arrays. Es fügt Ihrem Paket etwa 6 KB hinzu – für komplexe Geschäfte lohnt es sich, für einfache ist es unnötig.

Wie vermeide ich veraltete Abschlüsse in Zustandsaktionen?

Zustand übergibt den aktuellen Status an den set-Rückruf. Verwenden Sie daher immer die funktionale Form, wenn Sie den Status innerhalb einer Aktion lesen: set((state) => ({ count: state.count + 1 })), anstatt state.count aus dem Abschluss zu erfassen. Rufen Sie für asynchrone Aktionen, die den Status nach einem await lesen, getState() am Verwendungspunkt auf, anstatt den Status im Abschluss vor dem Warten zu erfassen.


Nächste Schritte

Der minimale Platzbedarf und das zusammensetzbare Slice-Muster von Zustand machen es zum richtigen Tool für den Client-Status in React-Anwendungen jeder Größe. In Kombination mit TanStack Query für den Serverstatus verfügen Sie über eine saubere, skalierbare Statusverwaltungsarchitektur ohne Standard-Overhead.

ECOSIRE erstellt React-Anwendungen mit Zustand + TanStack Query als Standard-Statusverwaltungs-Stack – für komplexe Admin-Panels, Kundenportale und E-Commerce-Abläufe. [Entdecken Sie unsere Frontend-Engineering-Dienste] (/services), um zu erfahren, wie wir skalierbare React-Anwendungen entwickeln.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp