Marketplace react-zustand-patterns
Zustand state management patterns for React. Use when working with Zustand stores, debugging state timing issues, or implementing async actions. Works for both React web and React Native.
git clone https://github.com/aiskillstore/marketplace
T=$(mktemp -d) && git clone --depth=1 https://github.com/aiskillstore/marketplace "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/cjharmath/react-zustand-patterns" ~/.claude/skills/aiskillstore-marketplace-react-zustand-patterns && rm -rf "$T"
skills/cjharmath/react-zustand-patterns/SKILL.mdZustand Patterns for React
Problem Statement
Zustand's simplicity hides important timing details.
set() is synchronous, but React re-renders are batched. getState() escapes stale closures. Async actions in stores need careful handling. Understanding these internals prevents subtle bugs.
Pattern: set() is Synchronous, Renders are Batched
Problem: Assuming state is "ready" for React immediately after
set().
const useStore = create((set, get) => ({ count: 0, increment: () => { set({ count: get().count + 1 }); // State IS updated here (set is sync) console.log(get().count); // ✅ Shows new value // But React hasn't re-rendered yet // Component will see old value until next render cycle }, }));
Key insight:
updates the store synchronouslyset()
immediately reflects the new valuegetState()- React components re-render asynchronously (batched)
When this matters:
- Chaining multiple state updates
- Validating state after update
- Debugging "stale" component values
Pattern: getState() Escapes Stale Closures
Problem: Callbacks and async functions capture state at creation time. Using
get() or getState() always gets current state.
const useStore = create((set, get) => ({ data: {}, // WRONG - closure captures stale state saveDataBad: (id: string, value: number) => { setTimeout(() => { // If someone passed `data` as a parameter, it would be stale }, 1000); }, // CORRECT - always use get() for current state saveData: async (id: string, value: number) => { await someAsyncOperation(); // After await, use get() to ensure current state const currentData = get().data; set({ data: { ...currentData, [id]: value } }); }, })); // In components - same principle function Component() { const data = useStore((s) => s.data); const handleSave = async () => { await delay(1000); // data here is stale! Captured at render time // Use getState() for current value const current = useStore.getState().data; }; }
Rule: After any
await, use get() or getState() - never rely on closure-captured values.
Pattern: Async Actions in Stores
Problem: Async actions need explicit
async/await and careful state reads after awaits.
const useStore = create((set, get) => ({ loading: false, data: null, error: null, // WRONG - no async keyword, race condition prone fetchDataBad: (id: string) => { set({ loading: true }); api.fetch(id).then((data) => { set({ data, loading: false }); }); // Returns immediately, caller can't await }, // CORRECT - proper async action fetchData: async (id: string) => { set({ loading: true, error: null }); try { const data = await api.fetch(id); // Re-read state after await if needed if (get().loading) { // Check we're still in loading state set({ data, loading: false }); } } catch (error) { set({ error: error.message, loading: false }); } }, })); // Caller can properly await await useStore.getState().fetchData('123');
Pattern: Selector Stability
Problem: Selectors that create new objects cause unnecessary re-renders.
// WRONG - creates new object every render const data = useStore((state) => ({ name: state.name, count: state.count, })); // CORRECT - use multiple selectors const name = useStore((state) => state.name); const count = useStore((state) => state.count); // OR - use shallow comparison (Zustand 4.x) import { shallow } from 'zustand/shallow'; const { name, count } = useStore( (state) => ({ name: state.name, count: state.count }), shallow ); // Zustand 5.x - use useShallow hook import { useShallow } from 'zustand/react/shallow'; const { name, count } = useStore( useShallow((state) => ({ name: state.name, count: state.count })) );
Pattern: Derived State
Problem: Computing derived values in selectors vs storing them.
const useStore = create((set, get) => ({ items: [], // WRONG - storing derived state that can become stale totalItems: 0, updateTotalItems: () => { set({ totalItems: get().items.length }); }, })); // CORRECT - compute in selector (always fresh) const totalItems = useStore((state) => state.items.length); // For expensive computations, memoize outside the store import { useMemo } from 'react'; function Component() { const items = useStore((state) => state.items); const expensiveResult = useMemo(() => { return computeExpensiveAnalysis(items); }, [items]); }
Pattern: Store Subscriptions for Side Effects
Problem: Need to react to state changes outside React components.
// Subscribe to specific state changes const unsubscribe = useStore.subscribe( (state) => state.data, (data, prevData) => { console.log('Data changed:', { prev: prevData, current: data }); // Persist to storage, send analytics, etc. }, { equalityFn: shallow } ); // In Zustand 4.x with subscribeWithSelector middleware import { subscribeWithSelector } from 'zustand/middleware'; const useStore = create( subscribeWithSelector((set, get) => ({ data: {}, // ... })) );
Pattern: Testing Zustand Stores
Problem: Tests need to reset store state and verify async flows.
// Store with reset capability const initialState = { data: {}, loading: false, }; const useStore = create((set, get) => ({ ...initialState, // Actions... // Reset for testing _reset: () => set(initialState), })); // Test describe('Data Store', () => { beforeEach(() => { useStore.getState()._reset(); }); it('fetches data correctly', async () => { const store = useStore.getState(); await store.fetchData('123'); expect(useStore.getState().data).toBeDefined(); expect(useStore.getState().loading).toBe(false); }); });
Pattern: Debugging State Changes
Problem: Tracking down when/where state changed unexpectedly.
// Add logging middleware import { devtools } from 'zustand/middleware'; const useStore = create( devtools( (set, get) => ({ // ... your store }), { name: 'MyStore' } ) ); // Manual logging for specific debugging const useStore = create((set, get) => ({ data: {}, saveData: (id: string, value: number) => { console.log('[saveData] Before:', { id, value, currentData: get().data, }); set((state) => ({ data: { ...state.data, [id]: value }, })); console.log('[saveData] After:', { data: get().data, }); }, }));
Pattern: Persist Middleware
Problem: Persisting state across sessions.
import { persist } from 'zustand/middleware'; // Web - localStorage const useStore = create( persist( (set, get) => ({ preferences: {}, setPreference: (key, value) => set((state) => ({ preferences: { ...state.preferences, [key]: value } })), }), { name: 'app-preferences', // Optional: choose what to persist partialize: (state) => ({ preferences: state.preferences }), } ) );
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Stale closure after await | Use after every await |
| Selector returns new object | Use or multiple selectors |
| Action not awaitable | Add keyword, return promise |
| State seems stale in component | Component hasn't re-rendered yet - use for immediate reads |
| Can't find when state changed | Add devtools middleware or manual logging |
Zustand 5.x Migration Notes
If upgrading from 4.x:
// 4.x - shallow from main package import { shallow } from 'zustand/shallow'; // 5.x - useShallow hook for React import { useShallow } from 'zustand/react/shallow'; // 4.x - type parameter often needed const useStore = create<StoreType>()((set, get) => ({...})); // 5.x - improved type inference const useStore = create((set, get) => ({...}));