Skip to main content
Technology & EngineeringState Management286 lines

Zustand State Management

Zustand minimal React state management — create store, selectors, middleware (persist, devtools, immer), slices pattern, and async actions

Quick Summary26 lines
Zustand is a small, fast, and scalable state management library for React. It embraces simplicity over ceremony: no providers, no boilerplate, no context wrappers. Stores are plain JavaScript objects enhanced with reactivity. Components subscribe to slices of state via selectors, ensuring minimal re-renders. Zustand works outside React too, making it ideal for shared logic between UI and non-UI code.

## Key Points

- **Minimal API surface** — one function (`create`) does everything
- **No providers required** — stores are module-level singletons
- **Selector-based subscriptions** — components only re-render when their selected state changes
- **Middleware composable** — persist, devtools, immer, and custom middleware stack naturally
- **TypeScript-first** — full type inference without extra generics in most cases
1. **One store per domain** — keep auth, cart, and UI state in separate stores rather than one mega-store.
2. **Always use selectors** — never destructure the entire store in a component.
3. **Colocate actions with state** — define mutations inside `create` so logic stays close to the data it modifies.
4. **Stack middleware deliberately** — `devtools(persist(immer(...)))` reads inside-out; immer runs first.
5. **Use `partialize` with persist** — avoid serializing functions or derived data to storage.
6. **Name devtools actions** — pass the third argument to `set()` for readable Redux DevTools logs.
7. **Derive computed values in selectors** — keep the store flat; compute totals and filters at read time.

## Quick Example

```bash
npm install zustand
# Optional middleware packages
npm install immer        # for immer middleware
```
skilldb get state-management-skills/Zustand State ManagementFull skill: 286 lines
Paste into your CLAUDE.md or agent config

Zustand State Management

Core Philosophy

Zustand is a small, fast, and scalable state management library for React. It embraces simplicity over ceremony: no providers, no boilerplate, no context wrappers. Stores are plain JavaScript objects enhanced with reactivity. Components subscribe to slices of state via selectors, ensuring minimal re-renders. Zustand works outside React too, making it ideal for shared logic between UI and non-UI code.

  • Minimal API surface — one function (create) does everything
  • No providers required — stores are module-level singletons
  • Selector-based subscriptions — components only re-render when their selected state changes
  • Middleware composable — persist, devtools, immer, and custom middleware stack naturally
  • TypeScript-first — full type inference without extra generics in most cases

Setup

npm install zustand
# Optional middleware packages
npm install immer        # for immer middleware
// store/useCounterStore.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => 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 }),
}));
// components/Counter.tsx
import { useCounterStore } from '../store/useCounterStore';

export function Counter() {
  const count = useCounterStore((s) => s.count);
  const increment = useCounterStore((s) => s.increment);

  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+1</button>
    </div>
  );
}

Key Techniques

Selectors and Preventing Re-renders

// Bad — subscribes to entire store, re-renders on any change
const state = useCounterStore();

// Good — only re-renders when `count` changes
const count = useCounterStore((s) => s.count);

// Multiple values with shallow equality
import { useShallow } from 'zustand/react/shallow';

const { count, increment } = useCounterStore(
  useShallow((s) => ({ count: s.count, increment: s.increment }))
);

Async Actions

interface TodoState {
  todos: Todo[];
  loading: boolean;
  error: string | null;
  fetchTodos: () => Promise<void>;
  addTodo: (title: string) => Promise<void>;
}

export const useTodoStore = create<TodoState>((set, get) => ({
  todos: [],
  loading: false,
  error: null,

  fetchTodos: async () => {
    set({ loading: true, error: null });
    try {
      const res = await fetch('/api/todos');
      const todos = await res.json();
      set({ todos, loading: false });
    } catch (err) {
      set({ error: (err as Error).message, loading: false });
    }
  },

  addTodo: async (title: string) => {
    const res = await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ title }),
      headers: { 'Content-Type': 'application/json' },
    });
    const newTodo = await res.json();
    set((state) => ({ todos: [...state.todos, newTodo] }));
  },
}));

Persist Middleware

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

interface SettingsState {
  theme: 'light' | 'dark';
  locale: string;
  setTheme: (theme: 'light' | 'dark') => void;
  setLocale: (locale: string) => void;
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      locale: 'en',
      setTheme: (theme) => set({ theme }),
      setLocale: (locale) => set({ locale }),
    }),
    {
      name: 'app-settings',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme, locale: state.locale }),
    }
  )
);

Devtools Middleware

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

export const useAuthStore = create<AuthState>()(
  devtools(
    (set) => ({
      user: null,
      login: async (credentials: Credentials) => {
        const user = await authApi.login(credentials);
        set({ user }, false, 'auth/login');
      },
      logout: () => set({ user: null }, false, 'auth/logout'),
    }),
    { name: 'AuthStore' }
  )
);

Immer Middleware

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

interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  updateQuantity: (id: string, qty: number) => void;
  removeItem: (id: string) => void;
}

export const useCartStore = create<CartState>()(
  immer((set) => ({
    items: [],
    addItem: (item) =>
      set((state) => {
        const existing = state.items.find((i) => i.id === item.id);
        if (existing) {
          existing.quantity += item.quantity;
        } else {
          state.items.push(item);
        }
      }),
    updateQuantity: (id, qty) =>
      set((state) => {
        const item = state.items.find((i) => i.id === id);
        if (item) item.quantity = qty;
      }),
    removeItem: (id) =>
      set((state) => {
        state.items = state.items.filter((i) => i.id !== id);
      }),
  }))
);

Slices Pattern for Large Stores

// store/slices/userSlice.ts
import { StateCreator } from 'zustand';

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

export const createUserSlice: StateCreator<
  UserSlice & CartSlice,
  [],
  [],
  UserSlice
> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
});

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

export const createCartSlice: StateCreator<
  UserSlice & CartSlice,
  [],
  [],
  CartSlice
> = (set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
});

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

export const useBoundStore = create<UserSlice & CartSlice>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
}));

Using Store Outside React

// Access state imperatively (e.g., in API interceptors)
const token = useAuthStore.getState().token;

// Subscribe to changes outside React
const unsub = useAuthStore.subscribe(
  (state) => state.user,
  (user) => console.log('User changed:', user)
);

Best Practices

  1. One store per domain — keep auth, cart, and UI state in separate stores rather than one mega-store.
  2. Always use selectors — never destructure the entire store in a component.
  3. Colocate actions with state — define mutations inside create so logic stays close to the data it modifies.
  4. Stack middleware deliberatelydevtools(persist(immer(...))) reads inside-out; immer runs first.
  5. Use partialize with persist — avoid serializing functions or derived data to storage.
  6. Name devtools actions — pass the third argument to set() for readable Redux DevTools logs.
  7. Derive computed values in selectors — keep the store flat; compute totals and filters at read time.

Anti-Patterns

  1. Subscribing to the whole storeconst state = useStore() triggers re-renders on every state change.
  2. Storing derived data — duplicating data that can be computed from existing state leads to sync bugs.
  3. Mutating state directly — without the immer middleware, you must always return new objects from set.
  4. Giant monolithic stores — a single store with 30+ fields becomes hard to reason about; split into slices or separate stores.
  5. Using getState() inside render — calling getState() in a component body bypasses reactivity; use selectors instead.
  6. Persisting functions or class instances — localStorage cannot serialize these; use partialize to exclude them.

Install this skill directly: skilldb add state-management-skills

Get CLI access →