Zustand Patterns Skill
This skill covers Zustand state management patterns for React applications.
When to Use
Use this skill when:
-
Managing global client state
-
Implementing authentication state
-
Creating shopping cart functionality
-
Building theme/settings management
Core Principle
SIMPLE BY DEFAULT - Zustand is minimal. Keep stores focused and use selectors for performance.
Basic Store
import { create } from 'zustand';
interface CounterState { count: number; increment: () => void; decrement: () => void; reset: () => void; }
export const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }), }));
// Usage function Counter(): React.ReactElement { const count = useCounterStore((state) => state.count); const increment = useCounterStore((state) => state.increment);
return ( <button onClick={increment}>Count: {count}</button> ); }
Typed Store with Actions
interface User { id: string; name: string; email: string; }
interface AuthState { user: User | null; isAuthenticated: boolean; isLoading: boolean; login: (credentials: { email: string; password: string }) => Promise<void>; logout: () => void; setUser: (user: User) => void; }
export const useAuthStore = create<AuthState>((set, get) => ({ user: null, isAuthenticated: false, isLoading: false,
login: async (credentials) => { set({ isLoading: true }); try { const response = await fetch('/api/login', { method: 'POST', body: JSON.stringify(credentials), }); const user = await response.json(); set({ user, isAuthenticated: true, isLoading: false }); } catch { set({ isLoading: false }); throw new Error('Login failed'); } },
logout: () => { set({ user: null, isAuthenticated: false }); },
setUser: (user) => { set({ user, isAuthenticated: true }); }, }));
Selectors for Performance
// ❌ Re-renders on any state change function Component(): React.ReactElement { const store = useAuthStore(); // Subscribes to entire store return <span>{store.user?.name}</span>; }
// ✅ Re-renders only when user changes function Component(): React.ReactElement { const user = useAuthStore((state) => state.user); return <span>{user?.name}</span>; }
// ✅ Multiple selectors function Component(): React.ReactElement { const user = useAuthStore((state) => state.user); const isLoading = useAuthStore((state) => state.isLoading); return isLoading ? <Loading /> : <span>{user?.name}</span>; }
// ✅ Computed selector function Component(): React.ReactElement { const userName = useAuthStore((state) => state.user?.name ?? 'Guest'); return <span>{userName}</span>; }
Middleware
Persist Middleware
import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsState { theme: 'light' | 'dark'; language: string; setTheme: (theme: 'light' | 'dark') => void; setLanguage: (language: string) => void; }
export const useSettingsStore = create<SettingsState>()( persist( (set) => ({ theme: 'light', language: 'en', setTheme: (theme) => set({ theme }), setLanguage: (language) => set({ language }), }), { name: 'settings-storage', storage: createJSONStorage(() => localStorage), partialize: (state) => ({ theme: state.theme, language: state.language, }), } ) );
DevTools Middleware
import { create } from 'zustand'; import { devtools } from 'zustand/middleware';
export const useStore = create<StoreState>()( devtools( (set) => ({ // state and actions }), { name: 'MyStore' } ) );
Combined Middleware
import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware';
export const useStore = create<StoreState>()( devtools( persist( (set) => ({ // state and actions }), { name: 'storage-key' } ), { name: 'DevToolsName' } ) );
Slices Pattern
Split large stores into slices:
// slices/userSlice.ts import { StateCreator } from 'zustand';
export interface UserSlice { user: User | null; setUser: (user: User) => void; clearUser: () => void; }
export const createUserSlice: StateCreator< UserSlice & CartSlice, [], [], UserSlice
= (set) => ({ user: null, setUser: (user) => set({ user }), clearUser: () => set({ user: null }), });
// slices/cartSlice.ts export interface CartSlice { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; clearCart: () => void; }
export const createCartSlice: StateCreator< UserSlice & CartSlice, [], [], CartSlice
= (set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), removeItem: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id), })), clearCart: () => set({ items: [] }), });
// store.ts import { create } from 'zustand'; import { createUserSlice, UserSlice } from './slices/userSlice'; import { createCartSlice, CartSlice } from './slices/cartSlice';
type StoreState = UserSlice & CartSlice;
export const useStore = create<StoreState>()((...a) => ({ ...createUserSlice(...a), ...createCartSlice(...a), }));
Async Actions
interface TodoState { todos: Todo[]; isLoading: boolean; error: string | null; fetchTodos: () => Promise<void>; addTodo: (text: string) => Promise<void>; }
export const useTodoStore = create<TodoState>((set, get) => ({ todos: [], isLoading: false, error: null,
fetchTodos: async () => { set({ isLoading: true, error: null }); try { const response = await fetch('/api/todos'); const todos = await response.json(); set({ todos, isLoading: false }); } catch (err) { set({ error: err instanceof Error ? err.message : 'Failed to fetch', isLoading: false, }); } },
addTodo: async (text) => { const optimisticTodo: Todo = { id: crypto.randomUUID(), text, completed: false, };
// Optimistic update
set((state) => ({ todos: [...state.todos, optimisticTodo] }));
try {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
});
const savedTodo = await response.json();
// Replace optimistic with real
set((state) => ({
todos: state.todos.map((t) =>
t.id === optimisticTodo.id ? savedTodo : t
),
}));
} catch {
// Rollback on error
set((state) => ({
todos: state.todos.filter((t) => t.id !== optimisticTodo.id),
}));
}
}, }));
Testing Stores
import { describe, it, expect, beforeEach } from 'vitest'; import { useAuthStore } from '../authStore';
describe('authStore', () => { beforeEach(() => { // Reset store before each test useAuthStore.setState({ user: null, isAuthenticated: false, isLoading: false, }); });
it('sets user on login', async () => { const user = { id: '1', name: 'Test', email: 'test@test.com' };
useAuthStore.getState().setUser(user);
expect(useAuthStore.getState().user).toEqual(user);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('clears user on logout', () => { useAuthStore.setState({ user: { id: '1' }, isAuthenticated: true });
useAuthStore.getState().logout();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
}); });
Best Practices
-
Use selectors - Always select specific state
-
Keep stores focused - One concern per store
-
Use TypeScript - Full type safety
-
Persist wisely - Only persist what's needed
-
Devtools in development - Easier debugging
-
Test store logic - Unit test actions
When NOT to Use Zustand
-
Server state → Use TanStack Query
-
Form state → Use React Hook Form
-
URL state → Use router params
-
Local component state → Use useState
Notes
-
Zustand is 1KB (smaller than Redux)
-
No providers needed (unlike Context)
-
Works outside React components
-
Compatible with React 19