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 zusammenimmer-Middleware ermöglicht direkte Mutationssyntax innerhalb von Aktionen (keine Spread-Operatoren)persist-Middleware mitlocalStorageodersessionStoragefür sitzungsübergreifenden Status- Die
devtools-Middleware lässt sich in die Redux DevTools-Erweiterung für das Debuggen von Zeitreisen integrieren – Selektoren mituseShallowverhindern 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.
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.
Verwandte Artikel
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
API Integration Patterns for OpenClaw Agents
Technical patterns for integrating OpenClaw AI agents with REST APIs, GraphQL, webhooks, and enterprise systems. Covers authentication, error handling, rate limiting, and testing.