Back to Blog
ReactZustandState ManagementHooks

Zustand for Simple State Management

Master Zustand for React state. From basic stores to middleware to persistence patterns.

B
Bootspring Team
Engineering
July 11, 2021
6 min read

Zustand provides minimal, flexible state management. Here's how to use it effectively.

Basic Store#

1import { create } from 'zustand'; 2 3interface CounterStore { 4 count: number; 5 increment: () => void; 6 decrement: () => void; 7 reset: () => void; 8} 9 10const useCounterStore = create<CounterStore>((set) => ({ 11 count: 0, 12 increment: () => set((state) => ({ count: state.count + 1 })), 13 decrement: () => set((state) => ({ count: state.count - 1 })), 14 reset: () => set({ count: 0 }), 15})); 16 17// Usage in component 18function Counter() { 19 const count = useCounterStore((state) => state.count); 20 const increment = useCounterStore((state) => state.increment); 21 22 return ( 23 <button onClick={increment}> 24 Count: {count} 25 </button> 26 ); 27} 28 29// Or destructure multiple values 30function Counter() { 31 const { count, increment, decrement } = useCounterStore(); 32 33 return ( 34 <div> 35 <button onClick={decrement}>-</button> 36 <span>{count}</span> 37 <button onClick={increment}>+</button> 38 </div> 39 ); 40}

Async Actions#

1interface UserStore { 2 user: User | null; 3 loading: boolean; 4 error: string | null; 5 fetchUser: (id: string) => Promise<void>; 6 updateUser: (data: Partial<User>) => Promise<void>; 7} 8 9const useUserStore = create<UserStore>((set, get) => ({ 10 user: null, 11 loading: false, 12 error: null, 13 14 fetchUser: async (id: string) => { 15 set({ loading: true, error: null }); 16 17 try { 18 const response = await fetch(`/api/users/${id}`); 19 const user = await response.json(); 20 set({ user, loading: false }); 21 } catch (error) { 22 set({ error: (error as Error).message, loading: false }); 23 } 24 }, 25 26 updateUser: async (data: Partial<User>) => { 27 const currentUser = get().user; 28 if (!currentUser) return; 29 30 // Optimistic update 31 set({ user: { ...currentUser, ...data } }); 32 33 try { 34 await fetch(`/api/users/${currentUser.id}`, { 35 method: 'PATCH', 36 body: JSON.stringify(data), 37 }); 38 } catch (error) { 39 // Rollback on error 40 set({ user: currentUser, error: (error as Error).message }); 41 } 42 }, 43}));

Selectors#

1// Selector to prevent unnecessary re-renders 2function UserName() { 3 // Only re-renders when user.name changes 4 const userName = useUserStore((state) => state.user?.name); 5 return <span>{userName}</span>; 6} 7 8// Multiple selectors with shallow comparison 9import { shallow } from 'zustand/shallow'; 10 11function UserInfo() { 12 const { name, email } = useUserStore( 13 (state) => ({ name: state.user?.name, email: state.user?.email }), 14 shallow 15 ); 16 17 return ( 18 <div> 19 <p>{name}</p> 20 <p>{email}</p> 21 </div> 22 ); 23} 24 25// Create reusable selectors 26const selectUser = (state: UserStore) => state.user; 27const selectLoading = (state: UserStore) => state.loading; 28const selectUserName = (state: UserStore) => state.user?.name; 29 30function Profile() { 31 const user = useUserStore(selectUser); 32 const loading = useUserStore(selectLoading); 33 34 if (loading) return <Spinner />; 35 return <div>{user?.name}</div>; 36}

Middleware#

1import { create } from 'zustand'; 2import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; 3import { immer } from 'zustand/middleware/immer'; 4 5interface TodoStore { 6 todos: Todo[]; 7 addTodo: (text: string) => void; 8 toggleTodo: (id: string) => void; 9 removeTodo: (id: string) => void; 10} 11 12const useTodoStore = create<TodoStore>()( 13 devtools( 14 persist( 15 subscribeWithSelector( 16 immer((set) => ({ 17 todos: [], 18 19 addTodo: (text: string) => 20 set((state) => { 21 state.todos.push({ 22 id: Date.now().toString(), 23 text, 24 completed: false, 25 }); 26 }), 27 28 toggleTodo: (id: string) => 29 set((state) => { 30 const todo = state.todos.find((t) => t.id === id); 31 if (todo) { 32 todo.completed = !todo.completed; 33 } 34 }), 35 36 removeTodo: (id: string) => 37 set((state) => { 38 state.todos = state.todos.filter((t) => t.id !== id); 39 }), 40 })) 41 ), 42 { 43 name: 'todo-storage', // localStorage key 44 } 45 ), 46 { name: 'TodoStore' } // DevTools name 47 ) 48);

Persist Middleware#

1import { persist, createJSONStorage } from 'zustand/middleware'; 2 3const useAuthStore = create<AuthStore>()( 4 persist( 5 (set) => ({ 6 token: null, 7 user: null, 8 setAuth: (token, user) => set({ token, user }), 9 logout: () => set({ token: null, user: null }), 10 }), 11 { 12 name: 'auth-storage', 13 storage: createJSONStorage(() => sessionStorage), // Default: localStorage 14 partialize: (state) => ({ token: state.token }), // Only persist token 15 onRehydrateStorage: () => (state) => { 16 console.log('Hydration finished', state); 17 }, 18 } 19 ) 20); 21 22// Custom storage (e.g., AsyncStorage for React Native) 23const useStore = create( 24 persist( 25 (set) => ({ ... }), 26 { 27 name: 'my-storage', 28 storage: createJSONStorage(() => ({ 29 getItem: async (name) => { 30 return await AsyncStorage.getItem(name); 31 }, 32 setItem: async (name, value) => { 33 await AsyncStorage.setItem(name, value); 34 }, 35 removeItem: async (name) => { 36 await AsyncStorage.removeItem(name); 37 }, 38 })), 39 } 40 ) 41);

Slices Pattern#

1// Split store into slices 2interface UserSlice { 3 user: User | null; 4 setUser: (user: User) => void; 5} 6 7interface CartSlice { 8 items: CartItem[]; 9 addItem: (item: CartItem) => void; 10 removeItem: (id: string) => void; 11} 12 13const createUserSlice = (set: any): UserSlice => ({ 14 user: null, 15 setUser: (user) => set({ user }), 16}); 17 18const createCartSlice = (set: any): CartSlice => ({ 19 items: [], 20 addItem: (item) => 21 set((state: any) => ({ items: [...state.items, item] })), 22 removeItem: (id) => 23 set((state: any) => ({ 24 items: state.items.filter((i: CartItem) => i.id !== id), 25 })), 26}); 27 28// Combine slices 29type StoreState = UserSlice & CartSlice; 30 31const useStore = create<StoreState>()((...args) => ({ 32 ...createUserSlice(...args), 33 ...createCartSlice(...args), 34}));

Subscribe to Changes#

1import { subscribeWithSelector } from 'zustand/middleware'; 2 3const useStore = create<Store>()( 4 subscribeWithSelector((set) => ({ 5 count: 0, 6 increment: () => set((state) => ({ count: state.count + 1 })), 7 })) 8); 9 10// Subscribe to specific state changes 11const unsubscribe = useStore.subscribe( 12 (state) => state.count, 13 (count, prevCount) => { 14 console.log('Count changed from', prevCount, 'to', count); 15 }, 16 { 17 equalityFn: shallow, 18 fireImmediately: true, 19 } 20); 21 22// Subscribe outside React 23useStore.subscribe((state) => { 24 console.log('State changed:', state); 25}); 26 27// Get state outside React 28const count = useStore.getState().count; 29 30// Set state outside React 31useStore.setState({ count: 10 });

With TypeScript#

1import { create, StateCreator } from 'zustand'; 2 3// Typed store creator for slices 4type UserSlice = { 5 user: User | null; 6 fetchUser: (id: string) => Promise<void>; 7}; 8 9type CartSlice = { 10 items: CartItem[]; 11 total: number; 12 addItem: (item: CartItem) => void; 13}; 14 15type Store = UserSlice & CartSlice; 16 17const createUserSlice: StateCreator<Store, [], [], UserSlice> = (set) => ({ 18 user: null, 19 fetchUser: async (id) => { 20 const user = await fetchUser(id); 21 set({ user }); 22 }, 23}); 24 25const createCartSlice: StateCreator<Store, [], [], CartSlice> = (set, get) => ({ 26 items: [], 27 total: 0, 28 addItem: (item) => 29 set((state) => ({ 30 items: [...state.items, item], 31 total: state.total + item.price, 32 })), 33}); 34 35const useStore = create<Store>()((...a) => ({ 36 ...createUserSlice(...a), 37 ...createCartSlice(...a), 38}));

Testing#

1import { act, renderHook } from '@testing-library/react'; 2 3// Reset store between tests 4const initialState = useStore.getState(); 5 6beforeEach(() => { 7 useStore.setState(initialState); 8}); 9 10test('increments count', () => { 11 const { result } = renderHook(() => useStore()); 12 13 act(() => { 14 result.current.increment(); 15 }); 16 17 expect(result.current.count).toBe(1); 18}); 19 20// Mock store for component tests 21const mockStore = create<Store>()((set) => ({ 22 count: 5, 23 increment: jest.fn(), 24})); 25 26jest.mock('./store', () => ({ 27 useStore: mockStore, 28}));

Best Practices#

Structure: ✓ Keep stores focused ✓ Use slices for large stores ✓ Separate actions from state ✓ Use TypeScript for safety Performance: ✓ Use selectors to minimize re-renders ✓ Use shallow comparison for objects ✓ Subscribe only to needed state ✓ Avoid selecting entire store Patterns: ✓ Use middleware for cross-cutting concerns ✓ Persist only necessary state ✓ Use immer for complex updates ✓ Reset stores in tests

Conclusion#

Zustand provides simple, powerful state management with minimal boilerplate. Use selectors for performance, middleware for persistence and devtools, and slices for organizing larger stores. Its hooks-based API integrates naturally with React while remaining flexible for complex use cases.

Share this article

Help spread the word about Bootspring