Zustand in React and TypeScript: Stores, Selectors, persist/devtools/immer and Next.js SSR

Key takeaways

Zustand keeps state in a small external store and lets each component subscribe to exactly the slice it selects. This guide covers typed stores, selector rules that changed in Zustand v5 (useShallow instead of an equality argument), persist/devtools/immer and their order, per-request stores for Next.js App Router, and the hydration and infinite-render errors people hit most.

Overview

Zustand is a small state management library for React. A store is a plain object of state and functions that update it; components subscribe to the parts they need through a hook. There are no actions, reducers, or providers to set up for the common case, TypeScript types are inferred from the store definition, and middleware adds persistence, Redux DevTools support, and Immer-style updates.

The design choice that matters most is that the store lives outside React. React Context re-renders every consumer when the context value changes; a Zustand store instead keeps a list of subscribers, and each component re-renders only when the value its selector returns changes. That makes selectors the center of performance in Zustand, and most of the problems people run into come from writing them in a way that returns a new value on every call.

Why Zustand?

Scenario 1: Too Much Boilerplate

Classic Redux requires action types, action creators, reducers, and a provider. Redux Toolkit removes much of that, but a Zustand store is still less code:

const count = useStore((state) => state.count);

Scenario 2: Bundle Size Matters

Zustand’s core is a few hundred lines and adds very little to a bundle, which is attractive for small apps and widgets. Redux Toolkit ships more features (RTK Query, serializable checks, entity adapters), and that extra code is the price of those features, not waste. Measure your own bundle with a tool such as source-map-explorer before treating size as the deciding factor.

Scenario 3: TypeScript Complexity

Zustand infers types from the store definition you write, so there is no separate action type union to maintain.

When not to use it: if most of your “state” is server data (lists, details, pagination), a data-fetching library such as TanStack Query or SWR handles caching, refetching, and invalidation better than a hand-written store. Zustand fits client state: UI toggles, form drafts, selection, editor state, and cross-component settings.


What is Zustand?

Core Features

Zustand is a small, fast, and scalable state management solution for React.

Key Advantages:

  • Simple API: create returns a hook; no provider is needed for client-only apps
  • Small: minimal runtime cost
  • TypeScript: type inference from the store definition
  • Middleware: persist, devtools, immer
  • Vanilla: the same store works outside React (zustand/vanilla)

Basic Usage

Installation

npm install zustand

Creating a Store

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

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

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

Two details are easy to miss. First, set merges the object you pass into the existing state at the top level, so set({ count: 0 }) keeps increment and the other functions. The merge is shallow: set({ user: { name: 'A' } }) replaces the whole user object, so nested updates need set((s) => ({ user: { ...s.user, name: 'A' } })) or the immer middleware. Second, the curried create<Store>()(...) form (note the extra ()) is what the Zustand docs recommend in TypeScript; it becomes required once you add middleware, because it lets TypeScript infer the middleware types correctly. Using it everywhere keeps stores consistent.

Using in Components

// components/Counter.tsx
import { useStore } from '../store/useStore';

export default function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  const decrement = useStore((state) => state.decrement);

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

Each useStore call subscribes separately, and the component re-renders only when one of the three selected values changes. Selecting the functions is free in practice: they are created once with the store and never change identity, so those subscriptions never trigger a render.


Advanced Patterns

Async Actions

interface UserStore {
  users: User[];
  loading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
}

export const useUserStore = create<UserStore>()((set) => ({
  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(`HTTP ${response.status}`);
      const users: User[] = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : String(error),
        loading: false,
      });
    }
  },
}));

Zustand has no special async support because it does not need any: an action is just a function that calls set whenever it wants. Two things in this version are deliberate. fetch does not reject on HTTP errors like 404 or 500, so without the response.ok check a failed request would be stored as if it were a user list. And in TypeScript’s strict mode the catch variable is unknown, so error.message does not compile ('error' is of type 'unknown'); narrowing with instanceof Error fixes that. If two fetchUsers calls can overlap, the slower one may overwrite the newer result; for anything beyond simple cases, this is exactly where a data-fetching library is the better tool.

Computed Values

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
}

export const useCartStore = create<CartStore>()((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  total: () => {
    const { items } = get();
    return items.reduce((sum, item) => sum + item.price, 0);
  },
}));

A function like total is fine for event handlers, but calling it during render has a catch: useCartStore((s) => s.total) selects the function, which never changes, so the component will not re-render when items changes. To render a derived value, compute it inside the selector instead: const total = useCartStore((s) => s.items.reduce((sum, i) => sum + i.price, 0));. The selector returns a number, so the component re-renders only when the total actually changes.


Middleware

Persist Middleware

Persist store to localStorage automatically:

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

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

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      sidebarOpen: true,
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'settings-storage',        // localStorage key
      partialize: (state) => ({ theme: state.theme }), // save only this field
      version: 1,                      // bump when the saved shape changes
    }
  )
);

persist saves the state as JSON under the name key and restores it when the store is created. Three options are worth setting from the start. partialize limits what is written, which keeps credentials and transient UI state out of storage. version together with migrate lets you change the saved shape later: without them, users who have an old value in localStorage get it merged into the new shape, which is a common source of “works on my machine, broken for returning users” bugs. And because values go through JSON, Date, Map, and Set do not survive a round trip unless you provide a custom storage with a reviver.

In server-rendered apps, persist is also the most common cause of hydration errors. The server renders with the initial state, the client restores the saved state from localStorage, and React reports Hydration failed because the initial UI does not match what was rendered on the server (or Text content does not match server-rendered HTML in older versions). The fixes are to render persisted values only after mount (for example, behind a useEffect that sets a hydrated flag), or to set skipHydration: true and call useSettingsStore.persist.rehydrate() yourself in a client effect.

Devtools Middleware

Debug with Redux DevTools:

import { devtools } from 'zustand/middleware';

export const useStore = create<Store>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set((state) => ({ count: state.count + 1 }), undefined, 'counter/increment'),
    }),
    { name: 'CounterStore' }
  )
);

The third argument to set names the action in the DevTools timeline; without it every update shows up as “anonymous”, which makes the timeline much less useful.

Immer Middleware

Write updates as mutations; Immer produces the immutable copy:

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

interface DeepStore {
  nested: { deep: { value: number } };
  updateDeep: (value: number) => void;
}

export const useDeepStore = create<DeepStore>()(
  immer((set) => ({
    nested: { deep: { value: 0 } },
    updateDeep: (value) =>
      set((state) => {
        state.nested.deep.value = value;
      }),
  }))
);

The immer middleware needs immer installed as a separate package. Inside an immer set, either mutate the draft or return a new object, not both; returning a value after mutating the draft throws [Immer] An immer producer returned a new value *and* modified its draft.

Middleware Order

When you combine middleware, the order changes what each layer sees. The Zustand docs recommend putting devtools outermost so that it records the final state after every other middleware has run:

export const useAppStore = create<AppStore>()(
  devtools(
    persist(
      immer((set) => ({ /* ... */ })),
      { name: 'app-storage' }
    ),
    { name: 'AppStore' }
  )
);

Slice Pattern

Split large stores into smaller slices:

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

export interface UserSlice {
  users: User[];
  fetchUsers: () => Promise<void>;
}

export const createUserSlice: StateCreator<UserSlice & CartSlice, [], [], UserSlice> = (set) => ({
  users: [],
  fetchUsers: async () => {
    const users = await api.getUsers();
    set({ users });
  },
});

// 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((state) => ({ items: [...state.items, item] })),
});

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

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

Slices are only a way to organize code; the result is still one store and one set of subscribers. Typing each slice with StateCreator<FullState, [], [], Slice> lets a slice read the whole state through get() while still being checked on its own. The empty arrays are the middleware “mutators”; when the combined store uses middleware such as immer or devtools, those lists have to name them, which is the most confusing part of typed slices. Because all slices are spread into one object, two slices that define the same key silently overwrite each other; prefix slice fields or keep slice names distinct.

An alternative is several independent stores (useUserStore, useCartStore). That is simpler to type and makes ownership obvious, at the cost of not being able to update both atomically in one set.


Selector Optimization

Anti-Pattern

// Subscribes to the entire store: re-renders on any change
const store = useStore();

Best Practice

// Subscribe only to needed values
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

Selecting Several Fields: useShallow

import { useShallow } from 'zustand/react/shallow';

const { count, increment } = useStore(
  useShallow((state) => ({ count: state.count, increment: state.increment }))
);

// Arrays work too
const [count2, increment2] = useStore(
  useShallow((state) => [state.count, state.increment])
);

A selector that builds an object or array creates a new reference on every call. Zustand compares the selector result with Object.is, so a fresh { count, increment } always looks different from the previous one. In Zustand v4 that meant the component re-rendered on every store change; in v5, which relies on React’s useSyncExternalStore, it can go further and produce Maximum update depth exceeded or a console warning that The result of getSnapshot should be cached to avoid an infinite loop. useShallow wraps the selector so the result is compared one field at a time and the previous reference is reused when nothing changed.

Older articles show useStore(selector, shallow) with an equality function as the second argument. That form was deprecated in v4.4 and removed from create in v5; if you need a custom equality function, use createWithEqualityFn from zustand/traditional. When upgrading an existing project to v5, searching for , shallow) is a quick way to find the call sites that need to change.

The same reference rule applies to derived arrays: useStore((s) => s.items.filter((i) => i.done)) returns a new array each time. Either compute it with useMemo from a selected items array, or wrap it in useShallow.


Vanilla Store (Without React)

Use Zustand outside React:

import { createStore } from 'zustand/vanilla';

const store = createStore<Store>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

// Subscribe
const unsubscribe = store.subscribe((state, prevState) => {
  console.log('Count:', prevState.count, '->', state.count);
});

// Use
store.getState().increment();
console.log(store.getState().count); // 1

// Unsubscribe
unsubscribe();

A vanilla store is useful for code that runs outside components: WebSocket handlers, analytics, or a non-React widget on the same page. In React you can bind it with useStore(store, selector) from zustand, which is also the basis of the per-request pattern in the next section. The subscribe listener receives both the new and the previous state, so it can react to specific changes without keeping its own copy.


Next.js App Router and SSR

A store created with create at module level is a singleton per JavaScript process. In the browser that is exactly what you want. On a Next.js server, one process renders requests for many users, so a module-level store shared across requests can leak one user’s state into another user’s HTML. The pattern recommended in the Zustand docs is to create the store per request and pass it down through React context:

// stores/counter-store.ts
import { createStore } from 'zustand/vanilla';

export type CounterState = { count: number };
export type CounterActions = { increment: () => void };
export type CounterStore = CounterState & CounterActions;

export const createCounterStore = (initState: CounterState = { count: 0 }) =>
  createStore<CounterStore>()((set) => ({
    ...initState,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }));
// providers/counter-store-provider.tsx
'use client';
import { createContext, useContext, useRef, type ReactNode } from 'react';
import { useStore } from 'zustand';
import { createCounterStore, type CounterStore, type CounterState } from '@/stores/counter-store';

type CounterStoreApi = ReturnType<typeof createCounterStore>;
const CounterStoreContext = createContext<CounterStoreApi | null>(null);

export function CounterStoreProvider({ children, initial }: { children: ReactNode; initial: CounterState }) {
  const storeRef = useRef<CounterStoreApi | null>(null);
  if (storeRef.current === null) {
    storeRef.current = createCounterStore(initial); // once per provider instance
  }
  return <CounterStoreContext.Provider value={storeRef.current}>{children}</CounterStoreContext.Provider>;
}

export function useCounterStore<T>(selector: (s: CounterStore) => T): T {
  const store = useContext(CounterStoreContext);
  if (!store) throw new Error('useCounterStore must be used inside CounterStoreProvider');
  return useStore(store, selector);
}

A Server Component fetches the initial data and renders <CounterStoreProvider initial={{ count: data.count }}>; client components below it call useCounterStore. Server Components themselves cannot use the store hook at all (hooks only run in client components), which is a useful constraint: server data flows in as props, and the store holds client state from that point on. The useRef guard matters, because creating the store directly in the render body would create a new store on every render and reset the state.


Troubleshooting

SymptomLikely causeFix
Component re-renders on every store changeSelector returns a new object or arraySelect fields separately or use useShallow
Maximum update depth exceeded after upgrading to v5Same as above, now loopinguseShallow, or return primitives
UI does not update when data changesComponent selected a function (s.total) instead of a valueCompute the value in the selector
Hydration error on first loadpersist restored localStorage state that differs from the server renderRender persisted values after mount, or skipHydration
One user sees another user’s data (SSR)Module-level store shared across server requestsPer-request store with a context provider
Tests leak state between casesThe module-level store keeps state across testsReset with useStore.setState(initialState, true) in beforeEach, or create stores per test
Infinite loopset called inside a subscribe listener without a guardCompare state and prevState before calling set

Most of these come back to the same model: a component re-renders when its selector’s return value changes by reference. When something re-renders too often, look for a selector that builds a new value; when something does not re-render, look for a selector that returns something stable (a function, or a value computed outside the selector).



Frequently Asked Questions (FAQ)

Q. When should I pick Redux Toolkit instead?

A. When you want enforced structure across a large team (every change is an action with a name), time-travel debugging as a core workflow, or RTK Query for server data in the same toolkit. Zustand is less opinionated, which is an advantage for small and medium apps and a risk for large teams without conventions.

Q. How is this different from React Context?

A. Context re-renders every component that reads it whenever the provided value changes, so putting frequently changing state in one context makes unrelated components re-render. Zustand subscriptions are per selector. Context is still the right tool for values that rarely change (theme, locale, the current user) and for scoping a store instance, as in the Next.js pattern above.

Q. Can I use it with React Native?

A. Yes, the API is the same. For persist, pass storage: createJSONStorage(() => AsyncStorage), because React Native has no localStorage; note that AsyncStorage is asynchronous, so the restored state arrives after the first render.

Q. How do I test components that use a store?

A. Reset the store before each test with useStore.setState(initialState, true) (the true replaces the whole state instead of merging), or build the store with a factory like createCounterStore so each test gets a fresh instance. The Zustand docs also show a Jest/Vitest mock that resets every store automatically after each test.