Skip to main content
The web app uses Zustand for state management when needed. Zustand is lightweight, has no boilerplate, and works seamlessly with React Server Components.

Why Zustand?

Minimal Boilerplate

No providers, actions, or reducers required

TypeScript First

Full type inference out of the box

React 19 Compatible

Works with Server and Client Components

DevTools Support

Built-in Redux DevTools integration

When to Use State Management

Global state shared across multiple componentsComplex state logic with multiple actionsPersistent state that needs to survive remountsDerived state with computed valuesState that changes frequently (real-time updates)

Installation

Zustand is not included by default. Add it when you need it:
bun add zustand

Basic Store

Create a store with actions:
features/counter/counter-store.ts
import { create } from 'zustand';

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

export 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 }),
}));

Using the Store

features/counter/counter-page.tsx
"use client";

import { useCounterStore } from './counter-store';
import { Button } from '@/components/ui/button';

export function CounterPage() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  
  return (
    <div>
      <p>Count: {count}</p>
      <Button onClick={increment}>+</Button>
      <Button onClick={decrement}>-</Button>
    </div>
  );
}
Use selector functions (state) => state.count to subscribe only to the state you need. This prevents unnecessary re-renders.

Advanced Patterns

Async Actions

Handle async operations in actions:
features/posts/posts-store.ts
import { create } from 'zustand';
import { getPosts } from './posts-api';

interface PostsStore {
  posts: Post[];
  loading: boolean;
  error: string | null;
  fetchPosts: () => Promise<void>;
}

export const usePostsStore = create<PostsStore>((set) => ({
  posts: [],
  loading: false,
  error: null,
  
  fetchPosts: async () => {
    set({ loading: true, error: null });
    try {
      const posts = await getPosts();
      set({ posts, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

Computed Values (Selectors)

Create derived state with selectors:
features/todos/todos-store.ts
import { create } from 'zustand';

interface TodosStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
}

export const useTodosStore = create<TodosStore>((set) => ({
  todos: [],
  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
    )
  })),
}));

// Computed selectors
export const useCompletedTodos = () => 
  useTodosStore((state) => state.todos.filter(t => t.completed));

export const useActiveTodos = () => 
  useTodosStore((state) => state.todos.filter(t => !t.completed));

export const useTodoStats = () => 
  useTodosStore((state) => ({
    total: state.todos.length,
    completed: state.todos.filter(t => t.completed).length,
    active: state.todos.filter(t => !t.completed).length,
  }));
Usage:
const completedTodos = useCompletedTodos();
const { total, completed, active } = useTodoStats();

Middleware - Persistence

Persist state to localStorage:
features/settings/settings-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

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

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'system',
      notifications: true,
      setTheme: (theme) => set({ theme }),
      toggleNotifications: () => set((state) => ({ 
        notifications: !state.notifications 
      })),
    }),
    {
      name: 'settings-storage', // localStorage key
    }
  )
);
Persisted stores can cause hydration mismatches in SSR. Wrap components using persisted stores in a client boundary.

Middleware - DevTools

Enable Redux DevTools for debugging:
features/app/app-store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface AppStore {
  user: User | null;
  setUser: (user: User) => void;
}

export const useAppStore = create<AppStore>()((
  devtools(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }, false, 'setUser'),
    }),
    { name: 'AppStore' }
  )
));

Slices Pattern

Split large stores into slices:
features/app/app-store.ts
import { create } from 'zustand';

// User slice
interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
  clearUser: () => void;
}

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

// Settings slice
interface SettingsSlice {
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
}

const createSettingsSlice = (set): SettingsSlice => ({
  theme: 'light',
  setTheme: (theme) => set({ theme }),
});

// Combined store
type AppStore = UserSlice & SettingsSlice;

export const useAppStore = create<AppStore>((set) => ({
  ...createUserSlice(set),
  ...createSettingsSlice(set),
}));

Best Practices

Selector Functions

Always use selectors to avoid unnecessary re-renders
// ✅ Good
const count = useStore(s => s.count);

// ❌ Bad - re-renders on any change
const { count } = useStore();

Co-locate Stores

Keep stores with their features
features/todos/
├── todos-page.tsx
└── todos-store.ts  ← Co-located

Type Safety

Always define TypeScript interfaces
interface Store {
  count: number;
  increment: () => void;
}
create<Store>((set) => ...)

Immutable Updates

Use immutable patterns in set()
// ✅ Good
set(state => ({ 
  items: [...state.items, newItem] 
}))

// ❌ Bad - mutates state
set(state => {
  state.items.push(newItem);
  return state;
})

Performance Optimization

Shallow Comparison

Use shallow for selecting multiple values:
import { shallow } from 'zustand/shallow';

const { count, increment, decrement } = useCounterStore(
  (state) => ({ 
    count: state.count, 
    increment: state.increment, 
    decrement: state.decrement 
  }),
  shallow
);

Separate Stores

Split frequently-changing and rarely-changing state:
// Fast-changing data
export const useRealtimeStore = create((set) => ({
  messages: [],
  addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
}));

// Slow-changing config
export const useConfigStore = create((set) => ({
  apiUrl: '',
  setApiUrl: (url) => set({ apiUrl: url }),
}));

Testing Stores

Test stores independently:
tests/counter-store.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from '@/features/counter/counter-store';

describe('CounterStore', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 });
  });
  
  it('increments count', () => {
    const { result } = renderHook(() => useCounterStore());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
});

Migration from Redux

// Actions
const INCREMENT = 'INCREMENT';
const increment = () => ({ type: INCREMENT });

// Reducer
const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    default:
      return state;
  }
};

// Store
const store = createStore(reducer);

// Provider
<Provider store={store}>
  <App />
</Provider>

// Usage
const count = useSelector(state => state.count);
const dispatch = useDispatch();
dispatch(increment());

Next Steps

Features

Build features with integrated state

Testing

Test components with Zustand stores

Zustand Docs

Official Zustand documentation

Build docs developers (and LLMs) love