../

Zustand

Client state with Zustand v5 (5.0.x) in React 19 and TypeScript: typed stores, selectors, middleware, vanilla stores, per-request stores for Next.js, and testing. Server data belongs in TanStack Query; hook basics are in React Hooks.

Setup

bun add zustand
bun add immer            # only for the immer middleware
# npm i zustand · pnpm add zustand
Entry pointExports
zustandcreate, createStore, useStore, types (StateCreator, StoreApi, ExtractState)
zustand/vanillacreateStore without React
zustand/middlewarepersist, createJSONStorage, devtools, subscribeWithSelector, combine, redux
zustand/middleware/immerimmer (peer dep immer)
zustand/react/shallowuseShallow
zustand/shallowshallow compare, useShallow
zustand/traditionalcreateWithEqualityFn, useStoreWithEqualityFn (peer dep use-sync-external-store)
v5 changeMeaning
React 18+, TS 4.5+uses React's built-in useSyncExternalStore
No default exportimport { create } from "zustand"
No equality fn on create hooksuse useShallow, or zustand/traditional
Stable selector outputs requireda selector returning a new object each call loops forever
setState(x, true)replace must pass the whole state (typed)
persistno longer writes to storage at store creation

Typed store

Call create<T>()(initializer): the extra () lets TS take T from you while it still infers the middleware types. The hook it returns is also the store API.

stores/counter.ts
import { create } from "zustand";
 
type CounterState = { count: number; step: number };
type CounterActions = {
  inc: () => void;
  setStep: (step: number) => void;
  reset: () => void;
};
export type CounterStore = CounterState & CounterActions;
 
const initial: CounterState = { count: 0, step: 1 };
 
export const useCounter = create<CounterStore>()((set) => ({
  ...initial,
  inc: () => set((s) => ({ count: s.count + s.step })),
  setStep: (step) => set({ step }),
  reset: () => set(initial),
}));
On the hookDoes
useCounter(selector)subscribe a component to selector(state)
useCounter()whole state; re-renders on every change
useCounter.getState()current state, no subscription
useCounter.setState(partial)update from anywhere
useCounter.getInitialState()state as first created
useCounter.subscribe(fn)(state, prev) => void; returns unsubscribe
Type helperUse
StateCreator<T, Mis, Mos, U>type an initializer or slice outside create
ExtractState<typeof useCounter>state type from a store or hook
StoreApi<T>getState/setState/subscribe/getInitialState
UseBoundStore<S>the hook type returned by create

Selectors and re-renders

A component re-renders when its selector's result changes by Object.is.

SelectorRe-renders when
(s) => s.countcount changes
(s) => s.incnever (actions are stable)
(s) => s.todos.lengththe length changes
(s) => s.todos.filter(f)always: new array, and in v5 an infinite loop
(s) => ({ a: s.a, b: s.b })always: new object, same loop
useShallow((s) => ({ a: s.a, b: s.b }))a or b changes (shallow compare)
useShallow((s) => Object.keys(s.map))the key list changes
no selectoranything in the store changes
import { useShallow } from "zustand/react/shallow";
import { useCounter } from "./stores/counter";
 
function Counter() {
  const count = useCounter((s) => s.count);   // atomic
  const inc = useCounter((s) => s.inc);
  const { step, setStep } = useCounter(
    useShallow((s) => ({
      step: s.step,
      setStep: s.setStep,
    })),
  ); // re-renders only when step changes
  return (
    <button
      onClick={inc}
      onContextMenu={() => setStep(step + 1)}
    >
      {count}
    </button>
  );
}

Auto-generated selectors

stores/create-selectors.ts
import type { StoreApi, UseBoundStore } from "zustand";
 
type WithSelectors<S> = S extends { getState: () => infer T }
  ? S & { use: { [K in keyof T]: () => T[K] } }
  : never;
 
export function createSelectors<
  S extends UseBoundStore<StoreApi<object>>,
>(store: S) {
  const s = store as WithSelectors<typeof store>;
  s.use = {} as WithSelectors<typeof store>["use"];
  for (const k of Object.keys(s.getState())) {
    (s.use as Record<string, () => unknown>)[k] = () =>
      s((state) => state[k as keyof typeof state]);
  }
  return s;
}
// const useC = createSelectors(useCounter); useC.use.count()

Actions: set and get

CallEffect
set({ a: 1 })shallow merge into state (top level only)
set((s) => ({ a: s.a + 1 }))merge, computed from current state
set(next, true)replace the whole state (must include actions)
get()read current state inside an action
store.setState / store.getStatesame, from outside React

Nested objects are not merged: spread every level you change, or use the immer middleware.

import { create } from "zustand";
 
type User = { id: string; name: string };
type Status = "idle" | "loading" | "error";
type UserStore = {
  user: User | null;
  prefs: { theme: "light" | "dark"; lang: string };
  status: Status;
  setTheme: (t: "light" | "dark") => void;
  load: (id: string, signal?: AbortSignal) => Promise<void>;
};
 
export const useUser = create<UserStore>()((set, get) => ({
  user: null,
  prefs: { theme: "light", lang: "en" },
  status: "idle",
  setTheme: (theme) =>
    set((s) => ({ prefs: { ...s.prefs, theme } })),
  load: async (id, signal) => {
    if (get().status === "loading") return;
    set({ status: "loading" });
    try {
      const res = await fetch(`/api/users/${id}`, {
        signal,
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const user = (await res.json()) as User;
      set({ user, status: "idle" });
    } catch {
      set({ status: "error" });
    }
  },
}));

Async actions are plain async functions that call set when ready; Zustand does not track them. For caching, retries and refetching of server data, use TanStack Query instead.

Actions outside the store

Module-level functions keep the store data-only and need no selector to call.

import { create } from "zustand";
 
export const useCart = create<{ ids: string[] }>()(() => ({
  ids: [],
}));
 
export const addToCart = (id: string) =>
  useCart.setState((s) => ({ ids: [...s.ids, id] }));
export const clearCart = () => useCart.setState({ ids: [] });

Slices pattern

Split a large store into typed slice creators, then spread them into one store. The second type argument lists middleware applied outside the slice; add middleware only in the combined store.

stores/app.ts
import { create, type StateCreator } from "zustand";
import { devtools } from "zustand/middleware";
 
type BearSlice = { bears: number; addBear: () => void };
type FishSlice = { fish: number; eatFish: () => void };
type AppStore = BearSlice & FishSlice;
 
type Mw = [["zustand/devtools", never]];
 
type Slice<T> = StateCreator<AppStore, Mw, [], T>;
 
const createBearSlice: Slice<BearSlice> = (set) => ({
  bears: 0,
  addBear: () =>
    set((s) => ({ bears: s.bears + 1 }), false, "bear/add"),
});
 
const createFishSlice: Slice<FishSlice> = (set, get) => ({
  fish: 10,
  eatFish: () => {
    if (get().bears === 0) return;   // cross-slice read
    set((s) => ({ fish: s.fish - 1 }), false, "fish/eat");
  },
});
 
export const useApp = create<AppStore>()(
  devtools((...a) => ({
    ...createBearSlice(...a),
    ...createFishSlice(...a),
  })),
);

Middleware

MiddlewareAddsMutator id
persist(fn, opts)save/restore to storage; store.persist.* API"zustand/persist"
devtools(fn, opts)Redux DevTools; set(x, replace, "action")"zustand/devtools"
immer(fn)mutate a draft inside set"zustand/immer"
subscribeWithSelector(fn)subscribe(selector, listener, opts)"zustand/subscribeWithSelector"
combine(state, fn)infers the type from initial statenone
redux(reducer, init)dispatch(action)"zustand/redux"

Order: devtools outermost, so it sees the final setState. A common stack is create<T>()(devtools(persist(immer(fn), opts))).

persist

stores/settings.ts
import { create } from "zustand";
import {
  createJSONStorage,
  persist,
} from "zustand/middleware";
 
type Settings = {
  theme: "light" | "dark";
  fontSize: number;
  draft: string;                       // not persisted
  setTheme: (t: Settings["theme"]) => void;
};
type Persisted = Pick<Settings, "theme" | "fontSize">;
 
export const useSettings = create<Settings>()(
  persist(
    (set) => ({
      theme: "light",
      fontSize: 16,
      draft: "",
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "settings",                // storage key
      storage: createJSONStorage(() => localStorage),
      partialize: (s): Persisted => ({
        theme: s.theme,
        fontSize: s.fontSize,
      }),
      version: 2,
      migrate: (old, from): Persisted => {
        // v1 stored `size`; v2 renamed it to `fontSize`
        type V1 = Partial<Persisted> & { size?: number };
        if (from < 2) {
          const v1 = old as V1;
          return {
            theme: v1.theme ?? "light",
            fontSize: v1.size ?? 16,
          };
        }
        return old as Persisted;
      },
    },
  ),
);
OptionDefaultNotes
namerequiredunique storage key
storagecreateJSONStorage(() => localStorage)sessionStorage, IndexedDB via an async StateStorage
partializewhole statepick what to save; functions are dropped by JSON anyway
version0bump on breaking shape changes
migrate(old, v)noneruns when stored version differs
merge(persisted, current)shallow mergedeep-merge nested objects here
onRehydrateStoragenone(state) => (state, error) => void
skipHydrationfalsehydrate manually with rehydrate() (SSR)
useSettings.persist.Does
rehydrate()read storage now
hasHydrated()true once storage was read
onFinishHydration(fn)listener; returns unsubscribe
clearStorage()remove the stored item
setOptions(opts)change name, storage, … at runtime

createJSONStorage(getStorage, { reviver, replacer }) lets you round-trip Map, Set or Date. localStorage is synchronous, so hydration finishes before the first render; async storages hydrate later, so gate the UI on hasHydrated().

devtools

import { create } from "zustand";
import { devtools } from "zustand/middleware";
 
type Todo = { id: string; done: boolean };
type TodoStore = {
  todos: Todo[];
  toggle: (id: string) => void;
};
 
export const useTodos = create<TodoStore>()(
  devtools(
    (set) => ({
      todos: [],
      toggle: (id) =>
        set(
          (s) => ({
            todos: s.todos.map((t) =>
              t.id === id ? { ...t, done: !t.done } : t,
            ),
          }),
          undefined,
          "todos/toggle",      // action name in DevTools
        ),
    }),
    {
      name: "todos",
      enabled: process.env.NODE_ENV !== "production",
    },
  ),
);

immer

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
 
type Board = {
  lists: Record<string, { title: string; cards: string[] }>;
  addCard: (listId: string, card: string) => void;
};
 
export const useBoard = create<Board>()(
  immer((set) => ({
    lists: { l1: { title: "Todo", cards: [] } },
    addCard: (listId, card) =>
      set((draft) => {
        // mutate the draft; immer makes the copy
        draft.lists[listId]?.cards.push(card);
      }),
  })),
);

Return nothing from an immer recipe (or return a whole new state, never both).

subscribeWithSelector and combine

import { create } from "zustand";
import {
  combine,
  subscribeWithSelector,
} from "zustand/middleware";
import { shallow } from "zustand/shallow";
 
// combine: state type inferred, no create<T>() needed
export const usePos = create(
  subscribeWithSelector(
    combine({ x: 0, y: 0 }, (set) => ({
      move: (x: number, y: number) => set({ x, y }),
    })),
  ),
);
 
const unsub = usePos.subscribe(
  (s) => [s.x, s.y] as const,
  ([x, y], [px, py]) => console.log(px, py, "->", x, y),
  { equalityFn: shallow, fireImmediately: true },
);
unsub();

Vanilla stores

createStore from zustand/vanilla builds a store with no React. Bind it to components with useStore(store, selector).

stores/timer.ts
import { createStore } from "zustand/vanilla";
 
type Timer = { ms: number; tick: (d: number) => void };
 
export const timerStore = createStore<Timer>()((set) => ({
  ms: 0,
  tick: (d) => set((s) => ({ ms: s.ms + d })),
}));
 
// works in workers, tests, plain TS, other frameworks
timerStore.getState().tick(16);
import { useStore } from "zustand";
import { timerStore } from "./stores/timer";
 
function Elapsed() {
  const ms = useStore(timerStore, (s) => s.ms);
  return <span>{(ms / 1000).toFixed(1)} s</span>;
}

Stores in context (SSR, Next.js)

A module-level store is shared by every request on a server. On Next.js, or whenever a subtree needs its own instance, create the store in a provider and pass it through context.

RuleWhy
Store factory, not a singletonone store per request (and per provider instance)
Provider is a Client Componenthooks and context need "use client"
Create once with useState(() => create())survives re-renders; useRef also works
Server Components never touch the storeno hooks, no context there
Seed with props from the serverinitial state matches on server and client
stores/cart-store.ts
import { createStore } from "zustand/vanilla";
 
export type CartState = { items: string[] };
export type CartActions = { add: (id: string) => void };
export type CartStore = CartState & CartActions;
 
export const createCartStore = (
  init: CartState = { items: [] },
) =>
  createStore<CartStore>()((set) => ({
    ...init,
    add: (id) => set((s) => ({ items: [...s.items, id] })),
  }));
providers/cart-store-provider.tsx
"use client";
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
import { useStore } from "zustand";
import {
  createCartStore,
  type CartState,
  type CartStore,
} from "@/stores/cart-store";
 
type CartApi = ReturnType<typeof createCartStore>;
const CartContext = createContext<CartApi | null>(null);
 
export function CartStoreProvider(props: {
  children: ReactNode;
  initial?: CartState;
}) {
  const [store] = useState(() =>
    createCartStore(props.initial),
  );
  return (
    <CartContext value={store}>{props.children}</CartContext>
  );
}
 
export function useCartStore<T>(
  sel: (s: CartStore) => T,
): T {
  const store = useContext(CartContext);
  if (!store) throw new Error("Missing CartStoreProvider");
  return useStore(store, sel);
}
app/layout.tsx
import type { ReactNode } from "react";
import {
  CartStoreProvider,
} from "@/providers/cart-store-provider";
 
export default function RootLayout(p: {
  children: ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <CartStoreProvider>{p.children}</CartStoreProvider>
      </body>
    </html>
  );
}

persist with SSR

The server has no localStorage, so the first client render must match the server. Set skipHydration: true and call useStore.persist.rehydrate() in a useEffect, or render stored values only after hasHydrated() is true. See Next.js.

File layout

Where store files live
src/stores/counter.ts               # global: create<T>()(...)settings.ts              # persisted storecart-store.ts            # factory: createCartStore()app/index.ts             # useApp = create(...slices)bear-slice.ts        # StateCreator<AppStore, …>fish-slice.tscreate-selectors.ts      # helpers shared by storesproviders/cart-store-provider.tsx  # "use client" context, hookfeatures/checkout/checkout-store.ts    # used by one feature onlytest/setup.ts                 # resets stores per test

One store per domain (not one giant store), colocated with the feature when only that feature uses it. Export hooks and actions, not the raw set.

Testing and resetting

Stores are module singletons, so state leaks between tests unless you reset it.

counter.test.ts
import { afterEach, expect, test } from "bun:test";
import { useCounter } from "./stores/counter";
 
afterEach(() => {
  useCounter.setState(useCounter.getInitialState(), true);
});
 
test("inc adds step", () => {
  useCounter.getState().setStep(5);
  useCounter.getState().inc();
  expect(useCounter.getState().count).toBe(5);
});
 
test("starts fresh", () => {
  expect(useCounter.getState().count).toBe(0);
});
ApproachWhen
Call actions via getState()unit-test store logic, no rendering
setState(getInitialState(), true) in afterEachreset one store
Wrapped create that records resetsreset every store (see Recipes)
__mocks__/zustand.tsJest/Vitest auto-mock that resets all stores (official guide)
Factory + provider with a fresh storecomponent tests with context stores
persist storesalso call store.persist.clearStorage()

Wrap state changes in act() from @testing-library/react when components are mounted.

Zustand vs context vs TanStack Query

NeedReach for
Data from a server (fetch, cache, refetch, mutate)TanStack Query
URL-shaped state (filters, tabs, page)search params
Form fieldslocal useState, form actions or a form library
State for one componentuseState / useReducer
Rarely-changing value for a subtree (theme, locale, DI)React context
Frequently-changing state shared across distant componentsZustand
Global UI state (modals, sidebar, selection, editor)Zustand
Read or write state outside React (sockets, workers, tests)Zustand (getState/subscribe)
Per-subtree instances or SSR requestsZustand store in context
ContextZustand
Re-rendersevery consumer on any value changeonly when the selected slice changes
Boilerplateprovider + hook per valueone create call
Outside Reactnoyes
Instancesone per providerglobal, or one per provider

Pitfalls

TrapFix
useStore() with no selectorselect the fields you use
Selector returns a new object/arrayuseShallow, or split into atomic selectors
Mutating state (s.items.push(x))return new objects, or use immer
Deep set({ a: { b } }) wipes siblingsspread each level: { a: { ...s.a, b } }
Server data copied into a storekeep it in TanStack Query; store only ids/UI state
Global store on a Next.js serverfactory + provider per request
Hydration mismatch with persistskipHydration + rehydrate() in an effect
create<T>(fn) with middlewareuse the curried create<T>()(fn)
set(x, true) missing actionsreplace keeps only what you pass: spread get()

Recipes

Typed store with persist and hydration flag

Persist a few fields and render stored values only after hydration. The outer onRehydrateStorage argument is the pre-hydration state, so actions work there even though usePrefs is not assigned yet.

import { create } from "zustand";
import { persist } from "zustand/middleware";
 
type Prefs = {
  theme: "light" | "dark";
  hydrated: boolean;
  setTheme: (t: Prefs["theme"]) => void;
  setHydrated: () => void;
};
 
export const usePrefs = create<Prefs>()(
  persist(
    (set) => ({
      theme: "light",
      hydrated: false,
      setTheme: (theme) => set({ theme }),
      setHydrated: () => set({ hydrated: true }),
    }),
    {
      name: "prefs",
      partialize: (s) => ({ theme: s.theme }),
      onRehydrateStorage: (s) => () => s.setHydrated(),
    },
  ),
);

Slices in separate files

Keep each slice typed against the full store so slices can call each other through get().

stores/app/auth-slice.ts
import type { StateCreator } from "zustand";
 
export type AuthSlice = {
  token: string | null;
  login: (t: string) => void;
};
export type UiSlice = {
  sidebar: boolean;
  toggleSidebar: () => void;
};
export type AppStore = AuthSlice & UiSlice;
 
export const createAuthSlice: StateCreator<
  AppStore, [], [], AuthSlice
> = (set) => ({
  token: null,
  login: (token) => set({ token, sidebar: true }),
});
// index.ts: create<AppStore>()((...a) => ({
//   ...createAuthSlice(...a), ...createUiSlice(...a) }))

Reset all stores

Record each store's initial state at creation, then reset everything on logout.

stores/create.ts
import { create as baseCreate } from "zustand";
import type { StateCreator } from "zustand";
 
const resets = new Set<() => void>();
 
export const create =
  <T,>() =>
  (init: StateCreator<T>) => {
    const store = baseCreate<T>()(init);
    resets.add(() =>
      store.setState(store.getInitialState(), true),
    );
    return store;
  };
 
export const resetAllStores = () => {
  for (const reset of resets) reset();
};

Derived selectors

Compute values in the selector when the result is a primitive; memoize arrays with useShallow or useMemo.

import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
 
type Item = { id: string; price: number; done: boolean };
const useItems = create<{ items: Item[] }>()(() => ({
  items: [],
}));
const selectTotal = (s: { items: Item[] }) =>
  s.items.reduce((sum, i) => sum + i.price, 0);
 
export function Summary() {
  const total = useItems(selectTotal);          // number
  const openIds = useItems(
    useShallow((s) =>
      s.items.filter((i) => !i.done).map((i) => i.id),
    ),
  );
  const items = useItems((s) => s.items);       // stable ref
  const sorted = useMemo(
    () => items.toSorted((a, b) => a.price - b.price),
    [items],
  );
  return <p>{total} · {openIds.length} · {sorted.length}</p>;
}

Store per component tree

Give each widget instance its own store, seeded from props.

import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
import { createStore, useStore } from "zustand";
 
type Tabs = { active: string; select: (id: string) => void };
const makeTabs = (active: string) =>
  createStore<Tabs>()((set) => ({
    active,
    select: (id) => set({ active: id }),
  }));
type TabsApi = ReturnType<typeof makeTabs>;
const Ctx = createContext<TabsApi | null>(null);
type RootProps = { start: string; children: ReactNode };
 
export function TabsRoot(p: RootProps) {
  const [store] = useState(() => makeTabs(p.start));
  return <Ctx value={store}>{p.children}</Ctx>;
}
 
export function useTabs<T>(sel: (s: Tabs) => T): T {
  const store = useContext(Ctx);
  if (!store) throw new Error("useTabs outside TabsRoot");
  return useStore(store, sel);
}

Subscribe outside React

Sync a store to a socket, the URL or document.title without rendering anything.

import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
 
type Chat = { unread: number; room: string };
export const useChat = create<Chat>()(
  subscribeWithSelector(() => ({
    unread: 0,
    room: "general",
  })),
);
 
const stopTitle = useChat.subscribe(
  (s) => s.unread,
  (n) => {
    document.title = n > 0 ? `(${n}) Chat` : "Chat";
  },
  { fireImmediately: true },
);
 
declare const socket: WebSocket;
socket.addEventListener("message", () =>
  useChat.setState((s) => ({ unread: s.unread + 1 })),
);
// later: stopTitle();

References