State & Data Flow
State & Data Flow

Zustand basics: create, selectors, set, persist

Full · ~30 min
After this unit you can
  • Create a store with create<T>()(…) and use getState/setState outside React, including resets between tests
  • Predict what a selector does in Zustand v5: a primitive re-renders only when it changes, a fresh object loops unless wrapped in useShallow
  • Predict the one-level merge in set() and in persist's rehydration, and use functional set when an update builds on the current state
  • Configure persist with name and partialize, and keep persisted values out of the hydration render under SSR
  • Write a store hook on useSyncExternalStore that re-renders only when the selected slice changes

Read

Simple
1 / 2

create() makes a hook; the selector decides who re-renders

Flag

A Zustand store is one call. create returns a hook that any component can import — no provider, no wrapper. This unit is about Zustand v5 (this app runs 5.0.x); where v4 behaved differently, it says so.

import { create } from "zustand";

type CounterState = {
  count: number;
  inc: () => void;
  reset: () => void;
};

export const useCounter = create<CounterState>()((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
  reset: () => set({ count: 0 }),
}));

The empty () after create<CounterState> isn't a typo: it's the curried form Zustand's TypeScript guide recommends whenever you annotate the state type (the medium tier explains why).

Note that actions live in the store, next to the data they change. Components call inc(); they never reach in and assign count.

Reading it, always with a selector:

const count = useCounter((s) => s.count);   // ✅ re-renders only when count changes
const { count } = useCounter();             // ⚠️ no selector = the whole state: re-renders on ANY change
const inc = useCounter((s) => s.inc);       // ✅ a function identity that never changes

That selector is the whole difference from Context: the component subscribes to the value it selected, compared with Object.is. Change some unrelated field and this component doesn't re-render at all.

One rule to carry into the next tier: a selector should return something that stays Object.is-equal while nothing it reads has changed — a field, not a freshly built object.

Medium

Selector identity, and persisting a store

Flag

The selector rule (Zustand v5)

The hook create returns is React's useSyncExternalStore with () => selector(getState()) as its snapshot. React compares snapshots with Object.is and treats any difference as a change. So a selector that builds a new object or array on every call never compares equal — not even when nothing in the store moved — and React keeps re-rendering until it gives up with "Maximum update depth exceeded" (after warning, in development, that "the result of getSnapshot should be cached").

// ❌ a new object on every call → an infinite render loop in v5
const { count, name } = useStore((s) => ({ count: s.count, name: s.name }));

// ✅ two atomic selectors, each a primitive
const count = useStore((s) => s.count);
const name = useStore((s) => s.name);

// ✅ or one selector wrapped in useShallow
import { useShallow } from "zustand/react/shallow";
const { count, name } = useStore(useShallow((s) => ({ count: s.count, name: s.name })));

useShallow remembers the previous result and hands it back while the new one is shallow-equal, so the snapshot only changes when a field does. Prefer atomic selectors; reach for useShallow when you genuinely need a derived object or array (a filtered list, Object.keys(s)). The same trap catches an inline fallback: s.onSave ?? (() => {}) is a new function on every call — hoist the fallback to a module-level constant.

v4 was different. There, the same object selector didn't loop; it re-rendered the component on every store change. v5 dropped the per-hook equality function, so v4 code that passed shallow as the hook's second argument needs useShallow (or createWithEqualityFn from zustand/traditional, which keeps the v4 behaviour).

persist: localStorage in one line

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

export const useSettings = create<Settings>()(
  persist(
    (set) => ({
      theme: "system",
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "whetstone-settings",              // the storage key
      partialize: (s) => ({ theme: s.theme }), // persist only the durable state
      version: 1,
      migrate: (persisted, from) => (from === 0 ? upgrade(persisted) : persisted),
    },
  ),
);

Note create<Settings>()( … ). Zustand's TypeScript guide recommends this curried form whenever you annotate the state type: TypeScript can't infer some type arguments while you pass others, and the extra call works around that. Without middleware, create<Settings>(…) happens to compile; with persist wrapping the initializer it doesn't — so write the curried form every time.

Details that bite:

  • partialize — chooses which state is durable. You don't need it to strip actions (JSON serialization drops function values, and rehydration shallow-merges over the initializer, so your actions come back intact regardless). You need it to keep ephemeral, derived, or sensitive fields out of localStorage: a smaller payload, nothing private on disk, and less shape for a future migrate to worry about.
  • version + migrate — the shape you persist today will change. When the stored version doesn't match the code's and there's no migrate, persist ignores the stored value rather than loading a shape your code no longer expects.
  • Rehydration merges one level deep, like set. The stored state is spread over the initial state ({ ...current, ...persisted }), so a nested object comes back wholesale from storage. Add a field inside it later (prefs.fontSize) and every returning user's saved prefs lacks it. Pass a deep merge option, or bump version and fill the gap in migrate.
  • Hydration happens at creation. With synchronous storage like localStorage (the default), the store is already hydrated when create returns — before React renders anything — and useSettings.persist.hasHydrated() is already true. Only async storage (IndexedDB, React Native's AsyncStorage) hydrates later, in a microtask; that's when hasHydrated() and onFinishHydration are useful.
  • SSR: the server has no localStorage, so it renders the initial state, and the first client render must match it. Because the client store is already hydrated, hasHydrated() can't be your gate (and on the server, with no storage, persist doesn't attach useSettings.persist at all, so calling it in render throws there). Show persisted values only after mount — this app's useMounted() (lib/use-mounted.ts, used with lib/stores/interests-store.ts) is false during hydration and true after — or create the store with skipHydration: true and call useSettings.persist.rehydrate() in an effect.
Complex

What create() is actually doing — and testing a singleton

Flag

Zustand in ~30 lines

There's no magic under create: a variable, a Set of listeners, and React's useSyncExternalStore (added in React 18, which is v5's minimum).

type Listener = () => void;
type Store<T> = {
  getState: () => T;
  getInitialState: () => T;
  subscribe: (listener: Listener) => () => void;
};

function createStore<T>(init: (set: (partial: Partial<T>) => void) => T): Store<T> {
  let state: T;
  const listeners = new Set<Listener>();

  const set = (partial: Partial<T>) => {
    state = { ...state, ...partial }; // the one-level merge
    listeners.forEach((l) => l());    // notify every subscriber before set returns
  };

  state = init(set);
  const initialState = state;

  return {
    getState: () => state,
    getInitialState: () => initialState,
    subscribe: (l) => {
      listeners.add(l);
      return () => listeners.delete(l);
    },
  };
}

// The React binding: subscribe to the store, re-render only when the SELECTED value changes.
function useStore<T, U>(store: Store<T>, selector: (s: T) => U): U {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),        // client snapshot
    () => selector(store.getInitialState()), // server snapshot: SSR and the hydration render
  );
}

This is close to the real thing. In Zustand v5, the hook create returns calls React's useSyncExternalStore with exactly these two snapshot functions (wrapped in useCallback); the real setState adds the function form and the replace flag, and skips notifying when it's handed the current state object itself (set((s) => s)). Everything else Zustand ships — persist, devtools, immer, subscribeWithSelector — is middleware: a function around your initializer that swaps in its own set or patches the store's API (persist writes to storage on every set; subscribeWithSelector replaces subscribe).

Two behaviours fall straight out of useSyncExternalStore:

  • Selector identity. React re-renders whenever the snapshot differs by Object.is from the last one it saw, and it re-reads the snapshot after rendering to check. A selector that returns a new object is different every time, so the component never settles: "Maximum update depth exceeded". An atomic selector returns the same primitive until the field changes.
  • Tearing. Every component reading the store during one render sees the same snapshot, even under concurrent rendering — something a hand-rolled useState + useEffect subscription doesn't guarantee.

v4 worked differently. Its hook used useSyncExternalStoreWithSelector from the use-sync-external-store shim, which memoizes the selection and accepts an equalityFn. That's why an object selector "only" re-rendered on every store change in v4 instead of looping. In v5 that path survives only as createWithEqualityFn in zustand/traditional.

Testing a module-level store

The store is a singleton created at import time, so state leaks between tests. Reset it explicitly:

beforeEach(() => {
  useCounter.setState(useCounter.getInitialState(), true); // `true` = replace, not merge
});

With many stores, Zustand's testing guide automates this: a shared __mocks__/zustand.ts wraps create, records each store's initial state as it's created, and resets every store after each test. Jest picks up a root __mocks__ folder for a package automatically; Vitest needs vi.mock("zustand") in a setup file.

Test the store itself without React (useCounter.getState().inc() then assert on getState().count) — plain functions, no renderer. Save component tests for the wiring.

When Zustand is the wrong answer

  • The value is server data → a query cache owns it.
  • The value belongs in the URL → the URL owns it.
  • Only one screen uses it → local state, or a store created per screen and provided through Context so it dies with the screen (module-level stores are app-lifetime by definition).
  • You need time-travel debugging and strict action logs across a large team → Redux Toolkit's ceremony starts paying for itself.

Quiz

What does const useCounter = create<CounterState>()((set) => ({ … })) give you?

Flag

In Zustand v5 a component needs both count and name. Which call re-renders it only when one of them changes?

Flag

State is { filters: { status: "open", page: 2 } } and you call set({ filters: { status: "closed" } }). What is filters afterwards?

Flag

When does set({ count: count + 1 }) lose an update that set((s) => ({ count: s.count + 1 })) would not?

Flag

Why does a persisted store usually need partialize?

Flag

A Next.js page renders useSettings.persist?.hasHydrated() ? useSettings.getState().theme : "system" from a store persisted to localStorage (the ?. is needed: on the server there is no storage, so persist never attaches useSettings.persist). The server rendered system; the saved theme is dark. What happens on the client's first render?

Flag

Two tests in one file interfere: the second starts with the count the first left behind. What's the fix?

Flag

Fill in the blank

Create a counter store whose increment reads the previous value.

Flag
export const useCounter = <CounterState>()((set) => ({
  count: 0,
  inc: () => ((s) => ({ count: s.count + 1 })),
}));

Subscribe to just the count, so unrelated store changes don't re-render this component.

Flag
const count = useCounter((s) => s.);

Read and write the store from plain, non-React code (a test, an event handler in a module).

Flag
useCounter.().inc();
useCounter.({ count: 0 });

Persist only the theme under a named storage key.

Flag
create<Settings>()(
  (
    (set) => ({ theme: "system", setTheme: (t) => set({ theme: t }) }),
    { name: "whetstone-settings", : (s) => ({ theme: s.theme }) },
  ),
);

Predict the output

const useStore = create((set) => ({ count: 0, name: "Ana", setName: (name) => set({ name }) }));

function CountLabel() {
  const { count } = useStore((s) => ({ count: s.count }));
  console.log("CountLabel render");
  return <span>{count}</span>;
}
Flag
// In the browser. Last month's release saved this under "settings":
//   { "state": { "prefs": { "theme": "dark" } }, "version": 0 }

// This release adds a nested default:
const useSettings = create(
  persist(() => ({ prefs: { theme: "system", fontSize: 16 } }), { name: "settings" }),
);

console.log(useSettings.getState().prefs);
Flag
const useCart = create(() => ({ count: 0 }));

async function addOne() {
  const { count } = useCart.getState();
  await saveToServer(); // any await: a fetch, a timer
  useCart.setState({ count: count + 1 });
}

// count is 0. A double-click starts two addOne() calls that overlap.
await Promise.all([addOne(), addOne()]);
console.log(useCart.getState().count);
Flag

Challenge

Make the store hook subscribe per selector

Flag

counter-store.ts is a store outside React — a value, a Set of listeners, and a set that shallow-merges and notifies. That's what Zustand's create builds for you.

The React binding is the interesting part, and it's currently wrong: useStore copies the whole state into local state on every notification, so OtherLabel re-renders when count changes even though it only reads other. That's the Context behaviour a store is supposed to fix. It also subscribes in an effect, so a store change made while the component is mounting (by a sibling's layout effect, say) never reaches it.

Your task

Edit useStore.ts so the tests pass:

  • useStore(selector) returns the selected slice of the current state, including a change made while the component was mounting
  • Changing a slice re-renders the components that select it
  • Changing a slice does not re-render a component selecting a different slice
  • The subscription is removed on unmount

Loading the in-browser editor…

Further reading