Back to articles
React

Simple State Management with Zustand

Learn how to manage application state efficiently using Zustand. Covers store creation, actions, selectors, and integration with React components.

8 mins read
ZustandState ManagementReactHooks

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

Terminal
npm install zustand
TypeScriptstores/counterStore.ts
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:

ReactCounter.tsx
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

TypeScriptstores/todoStore.ts
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

TypeScriptstores/userStore.ts
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

TypeScriptstores/settingsStore.ts
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

TypeScriptstores/appStore.ts
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

TypeScript__tests__/counterStore.test.ts
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);  });});
Simple API

No providers, reducers, or complex setup. Just create a store and use it.

TypeScript Ready

Excellent TypeScript support with full type inference and safety.

Lightweight

Tiny bundle size with no external dependencies. Perfect for any project size.

Flexible

Works with any React pattern. Use with hooks, classes, or server components.

💡

Best Practices

Use selective subscriptions to prevent unnecessary re-renders
Keep stores focused - separate concerns into different stores
Use middleware like immer for complex state updates
Persist only necessary data to localStorage
Test stores independently from components