Simple State Management with Zustand
Learn how to manage application state efficiently using Zustand. Covers store creation, actions, selectors, and integration with React components.
Zustand is a lightweight state management solution for React that eliminates the boilerplate typically associated with Redux while providing a simple and intuitive API. This guide covers everything from basic store creation to advanced patterns for complex applications.
Why Choose Zustand?
- Minimal boilerplate - no providers, reducers, or actions required
- TypeScript-first with excellent type inference
- Small bundle size (~1.3kb gzipped)
- Works with React 16.8+ and supports both hooks and class components
- Built-in devtools integration and middleware support
Basic Store Setup
npm install zustand
import { create } from 'zustand';interface CounterState { count: number; increment: () => void; decrement: () => void; reset: () => void; incrementBy: (amount: number) => 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 }), incrementBy: (amount) => set((state) => ({ count: state.count + amount })),}));
Using the store in components is straightforward:
import React from 'react';import { useCounterStore } from './stores/counterStore';export function Counter() { const { count, increment, decrement, reset } = useCounterStore(); return ( <div className="counter"> <h2>Count: {count}</h2> <div className="controls"> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> <button onClick={reset}>Reset</button> </div> </div> );}// Selective subscription for better performanceexport function CountDisplay() { const count = useCounterStore((state) => state.count); return <div>Current count: {count}</div>;}
Advanced Store Patterns
import { create } from 'zustand';import { immer } from 'zustand/middleware/immer';import { devtools } from 'zustand/middleware';interface Todo { id: string; text: string; completed: boolean; createdAt: Date;}interface TodoState { todos: Todo[]; filter: 'all' | 'active' | 'completed'; // Actions addTodo: (text: string) => void; toggleTodo: (id: string) => void; deleteTodo: (id: string) => void; editTodo: (id: string, text: string) => void; setFilter: (filter: TodoState['filter']) => void; clearCompleted: () => void; // Computed values filteredTodos: () => Todo[]; todoCount: () => number; completedCount: () => number;}export const useTodoStore = create<TodoState>()( devtools( immer((set, get) => ({ todos: [], filter: 'all', addTodo: (text: string) => set((state) => { state.todos.push({ id: crypto.randomUUID(), text, completed: false, createdAt: new Date(), }); }), toggleTodo: (id: string) => set((state) => { const todo = state.todos.find((t) => t.id === id); if (todo) { todo.completed = !todo.completed; } }), deleteTodo: (id: string) => set((state) => { state.todos = state.todos.filter((t) => t.id !== id); }), editTodo: (id: string, text: string) => set((state) => { const todo = state.todos.find((t) => t.id === id); if (todo) { todo.text = text; } }), setFilter: (filter) => set({ filter }), clearCompleted: () => set((state) => { state.todos = state.todos.filter((t) => !t.completed); }), // Computed values filteredTodos: () => { const { todos, filter } = get(); switch (filter) { case 'active': return todos.filter((t) => !t.completed); case 'completed': return todos.filter((t) => t.completed); default: return todos; } }, todoCount: () => get().todos.length, completedCount: () => get().todos.filter((t) => t.completed).length, })) ));
Async Actions and Side Effects
import { create } from 'zustand';interface User { id: number; name: string; email: string;}interface UserState { users: User[]; loading: boolean; error: string | null; fetchUsers: () => Promise<void>; addUser: (user: Omit<User, 'id'>) => Promise<void>; updateUser: (id: number, updates: Partial<User>) => Promise<void>; deleteUser: (id: number) => Promise<void>;}export const useUserStore = create<UserState>((set, get) => ({ users: [], loading: false, error: null, fetchUsers: async () => { set({ loading: true, error: null }); try { const response = await fetch('/api/users'); if (!response.ok) throw new Error('Failed to fetch users'); const users = await response.json(); set({ users, loading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Unknown error', loading: false }); } }, addUser: async (userData) => { set({ loading: true, error: null }); try { const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userData), }); if (!response.ok) throw new Error('Failed to add user'); const newUser = await response.json(); set((state) => ({ users: [...state.users, newUser], loading: false, })); } catch (error) { set({ error: error instanceof Error ? error.message : 'Unknown error', loading: false }); } }, updateUser: async (id, updates) => { try { const response = await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!response.ok) throw new Error('Failed to update user'); const updatedUser = await response.json(); set((state) => ({ users: state.users.map((user) => user.id === id ? updatedUser : user ), })); } catch (error) { set({ error: error instanceof Error ? error.message : 'Unknown error' }); } }, deleteUser: async (id) => { try { const response = await fetch(`/api/users/${id}`, { method: 'DELETE', }); if (!response.ok) throw new Error('Failed to delete user'); set((state) => ({ users: state.users.filter((user) => user.id !== id), })); } catch (error) { set({ error: error instanceof Error ? error.message : 'Unknown error' }); } },}));
Persistence with localStorage
import { create } from 'zustand';import { persist } from 'zustand/middleware';interface SettingsState { theme: 'light' | 'dark' | 'system'; language: string; notifications: boolean; setTheme: (theme: SettingsState['theme']) => void; setLanguage: (language: string) => void; toggleNotifications: () => void; resetSettings: () => void;}const defaultSettings = { theme: 'system' as const, language: 'en', notifications: true,};export const useSettingsStore = create<SettingsState>()( persist( (set) => ({ ...defaultSettings, setTheme: (theme) => set({ theme }), setLanguage: (language) => set({ language }), toggleNotifications: () => set((state) => ({ notifications: !state.notifications })), resetSettings: () => set(defaultSettings), }), { name: 'app-settings', // Optional: customize what gets persisted partialize: (state) => ({ theme: state.theme, language: state.language, notifications: state.notifications }), } ));
Store Composition and Slices
import { create } from 'zustand';// Define individual slicesinterface AuthSlice { user: User | null; isAuthenticated: boolean; login: (credentials: LoginCredentials) => Promise<void>; logout: () => void;}interface UISlice { sidebarOpen: boolean; theme: 'light' | 'dark'; toggleSidebar: () => void; setTheme: (theme: 'light' | 'dark') => void;}interface NotificationSlice { notifications: Notification[]; addNotification: (notification: Omit<Notification, 'id'>) => void; removeNotification: (id: string) => void;}// Create slice creatorsconst createAuthSlice = (set: any, get: any): AuthSlice => ({ user: null, isAuthenticated: false, login: async (credentials) => { try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) throw new Error('Login failed'); const user = await response.json(); set({ user, isAuthenticated: true }); } catch (error) { // Handle error with notification slice get().addNotification({ type: 'error', message: 'Login failed', }); } }, logout: () => set({ user: null, isAuthenticated: false }),});const createUISlice = (set: any): UISlice => ({ sidebarOpen: false, theme: 'light', toggleSidebar: () => set((state: any) => ({ sidebarOpen: !state.sidebarOpen })), setTheme: (theme) => set({ theme }),});const createNotificationSlice = (set: any): NotificationSlice => ({ notifications: [], addNotification: (notification) => set((state: any) => ({ notifications: [ ...state.notifications, { ...notification, id: crypto.randomUUID() }, ], })), removeNotification: (id) => set((state: any) => ({ notifications: state.notifications.filter((n: any) => n.id !== id), })),});// Combine all slicestype AppState = AuthSlice & UISlice & NotificationSlice;export const useAppStore = create<AppState>((set, get) => ({ ...createAuthSlice(set, get), ...createUISlice(set), ...createNotificationSlice(set),}));
Testing Zustand Stores
import { act, renderHook } from '@testing-library/react';import { useCounterStore } from '../stores/counterStore';// Reset store before each testbeforeEach(() => { useCounterStore.setState({ count: 0 });});describe('counterStore', () => { it('should increment count', () => { const { result } = renderHook(() => useCounterStore()); expect(result.current.count).toBe(0); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); it('should decrement count', () => { const { result } = renderHook(() => useCounterStore()); // Set initial count act(() => { useCounterStore.setState({ count: 5 }); }); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(4); }); it('should reset count', () => { const { result } = renderHook(() => useCounterStore()); // Set initial count act(() => { useCounterStore.setState({ count: 10 }); }); act(() => { result.current.reset(); }); expect(result.current.count).toBe(0); });});
No providers, reducers, or complex setup. Just create a store and use it.
Excellent TypeScript support with full type inference and safety.
Tiny bundle size with no external dependencies. Perfect for any project size.
Works with any React pattern. Use with hooks, classes, or server components.