Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~28 min· topic 3 of 6

Topic 15.3

Zustand

In one line

Zustand is a tiny external store library: create makes a hook that holds state and actions together, components read slices with selectors and re-render only when their slice changes, and middleware adds saving to localStorage and Redux DevTools.

Think of it like this

A notice board in a hostel corridor. Anyone can walk up and read it, and the warden can update it without asking each room. Students who care about the mess menu only look at the menu section; a change to the laundry timings doesn't make them come running. A Zustand store is that board: one place outside your components, readable from anywhere, and each component watches only the section it cares about.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Zustand
A tiny state library: one create call gives you a hook backed by an external store.
create
Makes a store from a function that returns state and actions, and returns a hook to read it.
set
Updates the store. It merges the object you give into the state (one level deep).
get
Reads the current state inside an action, without subscribing.
Action
A function stored in the store that changes the state, like increment.
Shallow comparison
Comparing two objects field by field at the top level, instead of checking if they're the same object.
useShallow
A Zustand helper that keeps a selector's result stable when its fields haven't changed.
Middleware
A wrapper that adds a feature to a store, like saving it (persist) or debugging it (devtools).
Hydration (persist)
Loading the saved state back from storage into the store when the app starts.

Step by step

01Install and create a store

In a real project: npm install zustand. A store is one file that exports a hook. State and the actions that change it sit side by side.

src/stores/useCart.jswhole filejsx
import { create } from "zustand";

export const useCart = create((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  remove: (id) => set((s) => ({ items: s.items.filter((x) => x.id !== id) })),
  clear: () => set({ items: [] }),
}));

02Read slices with selectors

Each component selects the smallest thing it needs. The header's badge selects only the count, so removing and re-adding the same number of items doesn't re-render it.

CartBadge.jsxwhole filejsx
function CartBadge() {
  const count = useCart((s) => s.items.length);
  return <span className="badge">{count}</span>;
}

function AddButton({ product }) {
  const add = useCart((s) => s.add); // actions never change: no re-renders
  return <button onClick={() => add(product)}>Add</button>;
}

03Pick several fields with useShallow

When one component needs several fields, wrap the selector so the result is compared field by field.

Summary.jsxwhole filejsx
import { useShallow } from "zustand/react/shallow";

const { items, coupon } = useCart(
  useShallow((s) => ({ items: s.items, coupon: s.coupon })),
);

04Add middleware

Wrap the store function. persist saves chosen fields to localStorage; devtools sends every change to the Redux DevTools extension. In TypeScript, use the curried form create<CartState>()(...) so middleware types work.

src/stores/useCart.tswhole filetsx
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";

type Item = { id: string; name: string; price: number };
type CartState = {
  items: Item[];
  add: (item: Item) => void;
};

export const useCart = create<CartState>()(
  devtools(
    persist(
      (set) => ({
        items: [],
        add: (item) => set((s) => ({ items: [...s.items, item] }), false, "cart/add"),
      }),
      { name: "cart", version: 1, partialize: (s) => ({ items: s.items }) },
    ),
    { name: "CartStore" },
  ),
);

05Use it outside React

useCart.getState(), useCart.setState() and useCart.subscribe() work anywhere: a socket handler can add a line, a logger can watch changes, and a test can reset the store with useCart.setState({ items: [] }).

Use it outside Reactdiagram
Rendering diagram…

Try it yourself

  1. 1

    Read the whole store

    In the first example, change CountLabel to const { count } = useCounter();. Predict which clicks now render it. Run it: it renders on step 5 too, because without a selector it subscribes to the whole state object.

  2. 2

    Replace instead of merge

    Change setStep to (step) => set({ step }, true). Predict what happens to count and to the actions. Run it and click Add step after picking a step: the state was replaced by { step }, so count and every action are gone (increment becomes undefined). The second argument is rarely what you want.

  3. 3

    Save one more field

    In the persist example, add menuOpen to partialize. Predict the two saved: lines. Run it: they now include "menuOpen":true. Then think about whether a learner would want the menu to reopen on every visit.

Code & diagrams

A Zustand store: selectors decide who re-renders New tab

No provider anywhere. Each click re-renders only the component whose selected value changed: `CountLabel` for count, `StepPicker` for step. `IncrementButton` selects an action, which never changes, so it renders once.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

  render CountLabel
  render StepPicker
  render IncrementButton
click Add step
  render CountLabel
click step 5
  render StepPicker
click Add step
  render CountLabel
  get() inside an action: 6
Several fields at once: shallow comparison New tab

`ping` changes `lastSeen`, which `Card` doesn't select: the new `{ name, city }` object is shallow-equal to the last one, so the old object is returned and `Card` is skipped. `rename` changes a selected field, so it renders. Without `useShallow` this selector would loop forever.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

  render Card: Asha from Pune
ping (lastSeen changes)
ping again
rename
  render Card: Asha K. from Pune
persist middleware and using the store outside React New tab

The store starts as `dark` because `persist` loaded the saved JSON when the store was created (localStorage loads synchronously). `partialize` keeps `menuOpen` out of storage. The preview's localStorage is in memory and resets on every Run; in a real browser the theme would survive a reload.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

loaded theme: dark
analytics: theme -> light
saved: {"state":{"theme":"light","fontSize":18},"version":0}
saved: {"state":{"theme":"light","fontSize":20},"version":0}
Per-request stores for server rendering (Next.js recipe)tsx
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { createStore, useStore } from "zustand";

type CounterState = { count: number; inc: () => void };
const createCounterStore = (initial: number) =>
  createStore<CounterState>()((set) => ({
    count: initial,
    inc: () => set((s) => ({ count: s.count + 1 })),
  }));

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

export function CounterStoreProvider({ initial, children }: { initial: number; children: ReactNode }) {
  // one store per mounted provider = one per request on the server
  const [store] = useState(() => createCounterStore(initial));
  return <CounterStoreContext value={store}>{children}</CounterStoreContext>;
}

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

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Selecting a new object without useShallow

In a Zustand 5 component, write const { count, step } = useCounter((s) => ({ count: s.count, step: s.step }));.

terminal
$ Console
── what you'll see ──
The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
An error occurred in the <Panel> component.

Break #2

Persisted data from an old app version

Version 1 of the app saved the cart as strings: { items: ["pen"] }. Version 2 expects objects and renders item.price.toFixed(2), but keeps the same name and sets no version.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of undefined (reading 'toFixed')

Myth vs fact

Myth

Zustand needs a Provider at the top of the app like Redux.

Fact

No provider is required: the store lives in a module and any component can call its hook. You add a context only for per-request stores in server rendering or for isolated test instances.

Myth

const { a, b } = useStore() is fine because you only use a and b.

Fact

Without a selector the component subscribes to the whole state and re-renders on every change in the store, including fields it never reads.

Myth

set replaces the state like useState's setter.

Fact

set merges one level deep. Nested objects are not merged: set({ user: { name } }) replaces user entirely, so spread the old nested object yourself.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Zustand 5 (October 2024) dropped React 17 support, removed the default export and the equality-function argument from create, and made useShallow the way to select objects. persist no longer stores the initial state at creation time; it writes on the first set. Check those when you upgrade from v4.

  • ▸

    persist with localStorage hydrates synchronously in the browser, but the server has no storage, so server-rendered HTML uses the initial state and the client may differ: a hydration mismatch (Topic 20.6). Use skipHydration: true and call useStore.persist.rehydrate() in an effect, or render storage-dependent parts only on the client.

  • ▸

    A store can grow by slices: separate functions that each create part of the state (createCartSlice(set, get), createUserSlice(set, get)) spread into one create call. That keeps one store (cross-slice actions stay easy) without one giant file.

  • ▸

    Actions living in the store make testing easy: call useCart.getState().add(item) and assert on useCart.getState() with no rendering. Reset between tests with useCart.setState(initialState, true).

Remember this

  1. 1

    Zustand (German for "state") is a small library (about 1 KB gzipped) built on the external store pattern from Topic 15.2. You call **create with a function that returns your initial state and** your actions in one object. create gives back a hook, conventionally named useSomethingStore or useSomething. There is no provider: any component can call the hook. Version 5 (current in 2026) needs React 18 or newer and uses React's own useSyncExternalStore inside.

  2. 2

    The function you pass to create receives **set and get**. set(partial) merges the object you give into the state (one level deep, like the old class component setState), so set({ step: 5 }) keeps count. set((state) => partial) computes the change from the current state, which you need when the new value depends on the old one. set(newState, true) replaces the whole state instead of merging. get() reads the current state inside an action, for logic that needs a value without changing it.

  3. 3

    Components read state with a selector: const count = useCounter((s) => s.count). The component re-renders only when the selected value changes (by Object.is). Actions are functions created once with the store, so selecting an action (useCounter((s) => s.increment)) never causes a re-render. Calling the hook with no selector (const { count } = useCounter()) returns the whole state object, which changes on every update, so that component re-renders on every change in the store. Avoid it except in tiny stores.

  4. 4

    To pick several values at once, a selector that returns a new object ((s) => ({ name: s.name, city: s.city })) causes an infinite loop in Zustand 5, because each call gives a new object. Either call the hook once per value (const name = useProfile((s) => s.name)), or wrap the selector in **useShallow** (import { useShallow } from "zustand/react/shallow", also exported from zustand/shallow). useShallow remembers the last result and returns it again if every field is the same by Object.is: a shallow comparison (compare each top-level field, not deep inside). Zustand 4 let you pass shallow as a second argument to the hook; version 5 removed that from create (it lives on in createWithEqualityFn from zustand/traditional).

  5. 5

    Middleware wraps the function you pass to create to add features. **persist** (from zustand/middleware) saves the state to storage (localStorage by default, as JSON shaped { state, version }) and loads it back when the store is created. Its options: name (the storage key, required), partialize (choose which fields to save, so UI flags and functions stay out), version plus migrate (upgrade data saved by an older version of your app), storage (for example createJSONStorage(() => sessionStorage)), and skipHydration. **devtools** connects the store to the Redux DevTools browser extension so you can see each change and travel back in time; name each change with set(partial, false, "cart/add"). Put devtools outermost: create(devtools(persist(fn, opts))). Other middleware: immer (mutate-style updates, needs the immer package), subscribeWithSelector, combine.

  6. 6

    The hook is also a store object you can use outside React: useCounter.getState() reads, useCounter.setState(partial) updates, and useCounter.subscribe((state, prev) => ...) listens (it returns an unsubscribe function). That makes Zustand handy for WebSocket handlers, analytics, tests and non-React code. For server rendering (Topic 20.1), a module-level store is shared by every request on the server, so frameworks like Next.js use **createStore** (from zustand/vanilla or zustand) to make one store per request, put it in context, and read it with useStore(store, selector).

Explain it without notes

01

How does Zustand decide which components re-render after a set?

02

What is the difference between set(partial), set(fn) and set(state, true)?

03

Why does Zustand 5 need useShallow for selectors that return objects?

04

How would you use a Zustand store safely with server rendering in Next.js?

Practice

01

Write a Zustand useTodos store with todos, add(text) and toggle(id), and a component that shows how many todos are done, re-rendering only when that number changes.

02

Add persist to useTodos so the list survives reloads under the key todos, saving only the todos array.

Trade-offs

  • ↔

    Zustand is tiny and has almost no boilerplate, but it gives you little structure: no enforced action log, no conventions for where logic lives. Large teams sometimes add their own rules (slices, naming actions for devtools) or choose Redux Toolkit for its conventions.

  • ↔

    Module-level stores are convenient in single-page apps but are shared singletons: per-request stores in context are needed for server rendering, and tests must reset the store between cases.

Done when you can

  • I can create a Zustand store with state and actions.

  • I read slices with selectors and never call the hook without one in big stores.

  • I can select several fields with useShallow.

  • I can persist chosen fields with persist, partialize and version.

  • I can read, update and subscribe to a store outside React.

  • I know why server rendering needs a store per request.