JavaScript 9 min read

Zustand: Lightweight React State Management Made Simple

Master Zustand for React state management. Learn store creation, async actions, middleware, persistence, and TypeScript integration patterns.

MR

Moshiour Rahman

Advertisement

What is Zustand?

Zustand is a small, fast, and scalable state management library for React. It offers a simpler API than Redux while providing powerful features like middleware and devtools support.

Why Zustand?

FeatureZustandReduxContext
Bundle Size~1KB~10KBBuilt-in
BoilerplateMinimalHighLow
Learning CurveEasySteepEasy
PerformanceExcellentGoodCan be slow
DevToolsYesYesLimited

Getting Started

Installation

npm install zustand

Basic Store

import { create } from 'zustand';

// Define store
interface CounterStore {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}));

// Use in component
function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Selecting State

// Select single value (component only re-renders when count changes)
function CountDisplay() {
  const count = useCounterStore((state) => state.count);
  return <p>Count: {count}</p>;
}

// Select multiple values
function Controls() {
  const { increment, decrement } = useCounterStore((state) => ({
    increment: state.increment,
    decrement: state.decrement
  }));

  return (
    <>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </>
  );
}

// Shallow comparison for objects
import { shallow } from 'zustand/shallow';

function UserInfo() {
  const { name, email } = useUserStore(
    (state) => ({ name: state.name, email: state.email }),
    shallow
  );
  return <p>{name} - {email}</p>;
}

Real-World Store Patterns

Todo Store

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodoStore {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  deleteTodo: (id: string) => void;
  setFilter: (filter: 'all' | 'active' | 'completed') => void;
  clearCompleted: () => void;
}

const useTodoStore = create<TodoStore>((set) => ({
  todos: [],
  filter: 'all',

  addTodo: (text) => set((state) => ({
    todos: [
      ...state.todos,
      { id: crypto.randomUUID(), text, completed: false }
    ]
  })),

  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  })),

  deleteTodo: (id) => set((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id)
  })),

  setFilter: (filter) => set({ filter }),

  clearCompleted: () => set((state) => ({
    todos: state.todos.filter((todo) => !todo.completed)
  }))
}));

// Derived state with selector
const useFilteredTodos = () => useTodoStore((state) => {
  switch (state.filter) {
    case 'active':
      return state.todos.filter((t) => !t.completed);
    case 'completed':
      return state.todos.filter((t) => t.completed);
    default:
      return state.todos;
  }
});

Auth Store

interface User {
  id: string;
  name: string;
  email: string;
}

interface AuthStore {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  checkAuth: () => Promise<void>;
}

const useAuthStore = create<AuthStore>((set, get) => ({
  user: null,
  token: null,
  isLoading: false,
  error: null,

  login: async (email, password) => {
    set({ isLoading: true, error: null });

    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });

      if (!response.ok) {
        throw new Error('Invalid credentials');
      }

      const { user, token } = await response.json();
      set({ user, token, isLoading: false });
      localStorage.setItem('token', token);

    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Login failed',
        isLoading: false
      });
    }
  },

  logout: () => {
    localStorage.removeItem('token');
    set({ user: null, token: null });
  },

  checkAuth: async () => {
    const token = localStorage.getItem('token');
    if (!token) return;

    set({ isLoading: true });

    try {
      const response = await fetch('/api/me', {
        headers: { Authorization: `Bearer ${token}` }
      });

      if (response.ok) {
        const user = await response.json();
        set({ user, token, isLoading: false });
      } else {
        localStorage.removeItem('token');
        set({ isLoading: false });
      }
    } catch {
      set({ isLoading: false });
    }
  }
}));

Shopping Cart Store

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (product: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  totalItems: () => number;
  totalPrice: () => number;
}

const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (product) => set((state) => {
    const existing = state.items.find((item) => item.id === product.id);

    if (existing) {
      return {
        items: state.items.map((item) =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      };
    }

    return { items: [...state.items, { ...product, quantity: 1 }] };
  }),

  removeItem: (id) => set((state) => ({
    items: state.items.filter((item) => item.id !== id)
  })),

  updateQuantity: (id, quantity) => set((state) => ({
    items: quantity <= 0
      ? state.items.filter((item) => item.id !== id)
      : state.items.map((item) =>
          item.id === id ? { ...item, quantity } : item
        )
  })),

  clearCart: () => set({ items: [] }),

  // Computed values using get()
  totalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0),

  totalPrice: () => get().items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )
}));

Async Actions

Fetching Data

interface Post {
  id: number;
  title: string;
  body: string;
}

interface PostStore {
  posts: Post[];
  isLoading: boolean;
  error: string | null;
  fetchPosts: () => Promise<void>;
  createPost: (post: Omit<Post, 'id'>) => Promise<void>;
}

const usePostStore = create<PostStore>((set, get) => ({
  posts: [],
  isLoading: false,
  error: null,

  fetchPosts: async () => {
    set({ isLoading: true, error: null });

    try {
      const response = await fetch('/api/posts');
      const posts = await response.json();
      set({ posts, isLoading: false });
    } catch (error) {
      set({
        error: 'Failed to fetch posts',
        isLoading: false
      });
    }
  },

  createPost: async (post) => {
    set({ isLoading: true });

    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post)
      });
      const newPost = await response.json();

      set((state) => ({
        posts: [...state.posts, newPost],
        isLoading: false
      }));
    } catch (error) {
      set({ error: 'Failed to create post', isLoading: false });
    }
  }
}));

Middleware

Persist Middleware

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface SettingsStore {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (language: string) => void;
  toggleNotifications: () => void;
}

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      notifications: true,
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleNotifications: () => set((state) => ({
        notifications: !state.notifications
      }))
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        theme: state.theme,
        language: state.language
      })  // Only persist these fields
    }
  )
);

DevTools Middleware

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useStore = create<Store>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set(
        (state) => ({ count: state.count + 1 }),
        false,
        'increment'  // Action name for DevTools
      )
    }),
    { name: 'MyStore' }
  )
);

Immer Middleware

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface Store {
  users: { id: string; name: string; settings: { theme: string } }[];
  updateUserTheme: (userId: string, theme: string) => void;
}

const useStore = create<Store>()(
  immer((set) => ({
    users: [],
    updateUserTheme: (userId, theme) => set((state) => {
      const user = state.users.find((u) => u.id === userId);
      if (user) {
        user.settings.theme = theme;  // Direct mutation with Immer
      }
    })
  }))
);

Combining Middleware

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

const useStore = create<Store>()(
  devtools(
    persist(
      immer((set) => ({
        // Store definition
      })),
      { name: 'my-storage' }
    ),
    { name: 'MyStore' }
  )
);

Slices Pattern

Splitting Large Stores

// userSlice.ts
export interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
  clearUser: () => void;
}

export const createUserSlice = (set: any): UserSlice => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null })
});

// cartSlice.ts
export interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  clearCart: () => void;
}

export const createCartSlice = (set: any): CartSlice => ({
  items: [],
  addItem: (item) => set((state: any) => ({
    items: [...state.items, item]
  })),
  clearCart: () => set({ items: [] })
});

// store.ts
import { create } from 'zustand';
import { createUserSlice, UserSlice } from './userSlice';
import { createCartSlice, CartSlice } from './cartSlice';

type Store = UserSlice & CartSlice;

const useStore = create<Store>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args)
}));

export default useStore;

Testing

Testing Stores

import { act, renderHook } from '@testing-library/react';
import { useCounterStore } from './counterStore';

describe('Counter Store', () => {
  beforeEach(() => {
    // Reset store before each test
    useCounterStore.setState({ count: 0 });
  });

  it('should increment count', () => {
    const { result } = renderHook(() => useCounterStore());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('should decrement count', () => {
    useCounterStore.setState({ count: 5 });

    const { result } = renderHook(() => useCounterStore());

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });
});

Mocking Stores

// __mocks__/store.ts
import { create } from 'zustand';

const mockStore = create(() => ({
  user: { id: '1', name: 'Test User' },
  isLoading: false,
  login: jest.fn(),
  logout: jest.fn()
}));

export default mockStore;

// In test file
jest.mock('./store');

Best Practices

1. Keep Stores Focused

// Good: Separate concerns
const useAuthStore = create<AuthStore>(...);
const useCartStore = create<CartStore>(...);
const useUIStore = create<UIStore>(...);

// Avoid: One giant store
const useStore = create<EverythingStore>(...);

2. Use Selectors

// Good: Only re-renders when count changes
const count = useStore((state) => state.count);

// Avoid: Re-renders on any state change
const state = useStore();

3. Memoize Selectors for Objects

import { shallow } from 'zustand/shallow';

// Good: Shallow comparison prevents unnecessary re-renders
const { name, email } = useStore(
  (state) => ({ name: state.name, email: state.email }),
  shallow
);

Summary

FeatureUsage
createCreate store
setUpdate state
getAccess state in actions
persistSave to storage
devtoolsRedux DevTools
immerImmutable updates
shallowPrevent re-renders

Zustand provides a simple yet powerful solution for React state management with minimal boilerplate.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.