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.