Engineering Notes
Home/Frontend/Ecosystem/Zustand
Frontend · Ecosystem

Zustand

Zustand is a tiny hook-based store with no provider and no action objects. Update state with set, and see selective subscriptions re-render only the components whose selected slice changed.

🎨 Frontend● Beginner⏱ 7 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • A minimal hook-based store without a provider
  • set: direct updates
  • Selective subscriptions
  • Multiple stores, middleware (persist, devtools) and internals

📚 Before you start

1A Minimal, Hook-Based Store — No Provider Required

Zustand is a small state-management library built around one core idea: a store is just a hook you call. There's no <Provider> wrapping your app, no action-type constants, no reducer switch statements — you define a store with create(), and any component imports and calls the resulting hook directly, exactly like useState, except the state lives outside React and is shared across every component that calls the hook.

tsx
import { create } from "zustand";

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

const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  return <button onClick={increment}>{count}</button>;
}

Contrast with Redux (previous two topics): no store needs to be threaded through a <Provider> at the app root, no dispatch({ type: ... }) ceremony — increment is a plain function living right next to the state it updates, called directly.

2set: Direct State Updates, No Action Objects

ts
const useStore = create<{ user: string | null; setUser: (name: string) => void; logout: () => void }>(
  (set, get) => ({
    user: null,
    setUser: (name) => set({ user: name }),        // shallow-merges { user: name } into state
    logout: () => set({ user: null }),
    greet: () => `Hello, ${get().user}`,             // get() reads current state outside a component
  })
);

set(partialState) shallow-merges the given object into the existing state (like this.setState in old-style class components) — you only specify the keys that changed, everything else is preserved automatically. set((state) => partialState) (the functional form, used in section 1's increment) is used whenever the update depends on the current value, avoiding stale-closure bugs the same way useState's updater-function form does. get() reads the current state from inside a store action, useful when one action's logic needs to read other pieces of state without waiting for a re-render.

3Selective Subscriptions: the Real Point of the Hook Argument

tsx
// Selects ONLY `count` — this component re-renders ONLY when count changes,
// never when unrelated store fields (like `user` in a combined store) change
const count = useCounterStore((state) => state.count);

// Selecting the WHOLE state object (no selector) re-renders on ANY change —
// almost always wrong for a store with more than one independent field
const wholeState = useCounterStore(); // avoid this pattern for multi-field stores

Every useStore(selector) call subscribes that component to exactly the slice selector returns, using reference/shallow equality to decide whether to re-render — passing no selector (or an identity selector returning the whole state) subscribes to every change, defeating the fine-grained re-render control that's the actual reason to pass a selector function at all. This is the same underlying concern as Redux's useSelector from the previous topics, expressed through a plain function argument instead of a separate hook.

4Multiple Independent Stores, or One Combined Store

ts
// Option A: separate stores per concern — each is its own hook
const useAuthStore = create<AuthState>(...);
const useCartStore = create<CartState>(...);

// Option B: one store, multiple logical "slices" combined manually
const useAppStore = create<AuthState & CartState>((set, get) => ({
  ...createAuthSlice(set, get),
  ...createCartSlice(set, get),
}));

Unlike Redux, which pushes toward one store with combineReducers-style slices, Zustand has no strong opinion here — many small independent stores (Option A) are just as idiomatic as one larger combined store (Option B), since there's no single-Provider constraint forcing consolidation. Teams pick based on how often different pieces of state actually change together.

5Middleware: persist, devtools, and Friends

ts
import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";

const useSettingsStore = create<SettingsState>()(
  devtools(
    persist(
      (set) => ({
        theme: "light",
        setTheme: (theme) => set({ theme }),
      }),
      { name: "settings-storage" } // localStorage key
    )
  )
);

Zustand supports optional middleware wrapping the store creator function — persist automatically syncs store state to localStorage (or another storage adapter) and rehydrates it on load, devtools wires the store into Redux DevTools for the same time-travel debugging experience covered in the Redux topics, applied here to a store with no Redux underneath it at all.

Under the HoodHow it works internally▾

Zustand's store is a plain external object with a subscribe/publish mechanism — React's involvement is limited to one small hook wrapping that subscription. create(initializer) builds a store object ({ getState, setState, subscribe }, conceptually similar to the plain-Redux store from the previous topics but without any reducer/action-type machinery) that lives entirely outside React's render tree, in a plain JS closure. The useStore hook it returns internally calls useSyncExternalStore (React's built-in primitive for subscribing a component to state that lives outside React, also what react-redux's modern useSelector is built on) with your selector function — React re-renders the component if the selector's result changes according to a comparison function (Object.is by default), and does nothing otherwise. There's no Context, no Provider tree walk, no reconciliation-triggered re-computation; it's a direct subscription from the component straight to the external store object.

set's shallow merge is a plain Object.assign-style spread, not Immer-powered draft mutation. Unlike Redux Toolkit's createSlice (previous topic), calling set({ count: 5 }) does not track deep mutations through a Proxy — it computes { ...currentState, ...partialState } (or, for the functional form, { ...currentState, ...updaterFn(currentState) }) and republishes that as the new state, notifying every subscribed selector to re-check. This is exactly why nested object updates in Zustand still require the same explicit-spread discipline that plain Redux reducers needed before Immer — set({ user: { ...state.user, name: "New" } }), not set((state) => { state.user.name = "New"; }), unless you separately opt into Zustand's own Immer-integration middleware.

A Zustand store lives outside React; each hook subscribes with a selector and re-renders only when its selected slice changes, while a selector-less hook re-renders on every change
A Zustand store lives outside React; each hook subscribes with a selector and re-renders only when its selected slice changes, while a selector-less hook re-renders on every change

⚠️ Common Pitfalls

  1. Selecting the whole store state with no selector function (useStore() instead of useStore((s) => s.count)) — subscribes the component to every field's changes, causing unnecessary re-renders exactly like skipping a Redux useSelector's narrowing.
  2. Mutating nested state directly inside set's updater without spreading, expecting Immer-like safety — plain Zustand does a shallow merge only; deep nested updates still need explicit spreading (or the separate Immer middleware).
  3. Creating a new store inside a component body (const useStore = create(...) written directly inside a component function) instead of at module scope — creates a brand-new, disconnected store on every render instead of one shared store all components subscribe to.
  4. Returning a brand-new object/array from a selector on every call (useStore((s) => ({ a: s.a, b: s.b }))) without a shallow-equality comparator — the default Object.is check sees a new object reference every time and re-renders on every store change regardless of whether a/b actually changed; Zustand exports a shallow comparator for exactly this case.
  5. Assuming persist middleware synchronizes across browser tabs automatically — by default it only writes to localStorage and rehydrates on load; live cross-tab sync requires additional wiring (listening for storage events), not something persist does on its own.

📋 Quick Reference

text
create<T>((set, get) => ({ ...state, ...actions }))   → returns a useStore hook, no Provider needed

set(partialState)               → shallow-merges into state
set((state) => partialState)    → functional form, avoids stale-closure reads
get()                            → reads current state from inside a store action

useStore((state) => state.slice)  → selective subscription, re-renders only when that slice changes
useStore()                         → subscribes to EVERYTHING — usually wrong for multi-field stores

persist(initializer, { name })    → middleware: syncs state to localStorage, rehydrates on load
devtools(initializer)              → middleware: wires into Redux DevTools

Mechanism: plain external object + useSyncExternalStore, no Context/Provider tree involved
set() does a shallow merge (Object.assign-style) — NOT Immer draft mutation, unlike RTK's createSlice

Contrast: Redux/RTK = one store, actions/reducers, Provider-based, strong single-store convention
          Zustand    = any number of stores, direct functions, no Provider, lighter ceremony

🧠 Key takeaways

  • create((set, get) => ({ ... })) returns a hook; no Provider needed.
  • set shallow-merges partial state; actions are just functions in the store.
  • Components subscribe with selectors; only changed selections re-render (Object.is).
  • Select narrowly, use useShallow for object selections; add persist/devtools middleware as needed.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is Zustand and why is it popular?core▾

A minimal state management library: create a store with a hook (create((set) => ({...}))), no providers or boilerplate, selective subscriptions to avoid re-renders, and middleware for persistence and devtools.

Follow-ups interviewers ask
  • Does it need a Provider? No, stores are module-level hooks (context can be used for per-instance stores).
Q2How do selectors prevent re-renders?core▾

Components subscribe to a slice: useStore(s => s.count) re-renders only when that value changes. Selecting objects needs useShallow to compare shallowly.

Follow-ups interviewers ask
  • Common mistake? useStore() without a selector subscribes to everything.
Q3How do you handle async actions?core▾

Define async functions in the store that call set when data arrives; or keep server state in TanStack Query and use Zustand only for client state.

Follow-ups interviewers ask
  • Access state outside React? useStore.getState() and subscribe.
Q4What middleware does Zustand offer?senior▾

persist (localStorage/sessionStorage), devtools (Redux DevTools), immer (mutable-style updates), and subscribeWithSelector.

Follow-ups interviewers ask
  • SSR caveat? Module-level stores are shared across requests on the server; create per-request stores.
Q5Zustand vs Redux Toolkit vs Context?core▾

Context: simple, low-frequency values. Zustand: lightweight global client state with good performance and little code. Redux Toolkit: structured patterns, middleware ecosystem and tooling for large teams.

Follow-ups interviewers ask
  • How would you choose? Team size, complexity, and need for conventions and devtools.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Does Zustand require a Provider?
Module-level store.
2. useStore() without a selector re-renders…
Whole store selected.
3. Selecting { a, b } as a new object each time causes…
New reference.
4. set({ count: 1 }) with other fields present…
Merge by default.