Skip to content
D
Documentation

How Zustand works

concept
3 min readUpdated

Zustand connects a store, the actions that update it, and selectors that let React components subscribe to only the state they use.

Install Zustand and React in a React project:

bash
npm install zustand react
mermaid
flowchart LR
  A["create()"] --> B["Store: state + actions"]
  B --> C["Selector"]
  C --> D["React component"]
  B --> E["setState()"]
  E --> B
  F["createStore()"] --> G["Vanilla StoreApi"]
  G --> H["useStore()"]
  H --> D

Updates are immutable and merge shallowly

Use the set function supplied to the initializer, or the store's setState, for updates. An object update is shallowly merged into the current state; a function update receives the current state. Nested objects need their own spread so that the untouched nested properties remain present.

tsx
import { create } from 'zustand'

type ProfileStore = {
  profile: {
    name: string
    settings: {
      compact: boolean
    }
  }
  rename: (name: string) => void
  setCompact: (compact: boolean) => void
}

const useProfile = create<ProfileStore>((set) => ({
  profile: {
    name: 'Ada',
    settings: { compact: false },
  },
  rename: (name) => set((state) => ({ profile: { ...state.profile, name } })),
  setCompact: (compact) =>
    set((state) => ({
      profile: {
        ...state.profile,
        settings: { ...state.profile.settings, compact },
      },
    })),
}))

export function Profile() {
  const profile = useProfile((state) => state.profile)
  const rename = useProfile((state) => state.rename)

  return (
    <label>
      Name
      <input value={profile.name} onChange={(event) => rename(event.target.value)} />
    </label>
  )
}

Typing in the input replaces only profile.name; the nested settings object remains part of the state because the update merges each level explicitly. Passing the replace flag to setState instead replaces the complete state model, including actions, so use it only when that is the intended result. The underlying store applies the merge or replacement and notifies listeners only when the next state is not Object.is-equal to the current state.

Selectors and equality control rendering

A selector narrows what a component reads. The React binding compares the selected result with Object.is, which makes atomic selections such as a number or function efficient. A selector that constructs a new object or array produces a new reference; wrap that selector with useShallow when shallow-equal results should reuse the previous reference.

tsx
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

type BasketStore = {
  apples: number
  oranges: number
  addApple: () => void
}

const useBasket = create<BasketStore>((set) => ({
  apples: 0,
  oranges: 0,
  addApple: () => set((state) => ({ apples: state.apples + 1 })),
}))

export function BasketSummary() {
  const fruit = useBasket(
    useShallow((state) => ({ apples: state.apples, oranges: state.oranges })),
  )

  return <p>{fruit.apples} apples, {fruit.oranges} oranges</p>
}

The component renders the two counts as one selected object. useShallow returns the previous object when its selected properties are shallow-equal. For a single primitive, select it directly instead of constructing an object.

Actions stay with the state

Zustand recommends a single store for an application's global state, with actions defined directly on that store. An action can be asynchronous: wait for the work, then call set. Use get in the initializer when an action needs the current state outside a functional update. Reducer-style actions remain an optional pattern rather than the store's required layer.

The initializer receives a typed StateCreator, whose arguments are the update function, the getter, and the store API. The store returned by create is a UseBoundStore: it is callable as a hook and also exposes the store API. ExtractState obtains the state type from an API; StoreApi describes setState, getState, getInitialState, and subscribe.

The generic type plumbing for middleware uses Mutate, StoreMutatorIdentifier, and StoreMutators. These types describe how a store API is changed by mutators; use them when extending middleware typings, not as a replacement for the store hook.

A vanilla store separates creation from React

Use createStore from the zustand/vanilla package when the store must exist without React. It returns a vanilla API rather than a hook. The API reads current state, updates it, exposes the initial state, and lets non-React code subscribe.

tsx
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'

type ClockStore = {
  label: string
  setLabel: (label: string) => void
}

const clockStore = createStore<ClockStore>((set) => ({
  label: 'Ready',
  setLabel: (label) => set({ label }),
}))

export function Clock() {
  const label = useStore(clockStore, (state) => state.label)
  const setLabel = useStore(clockStore, (state) => state.setLabel)

  return <button onClick={() => setLabel('Started')}>{label}</button>
}

The button initially shows Ready; clicking it updates the vanilla store and the bound component shows Started. useStore subscribes the component to that store without turning the store itself into a React hook. This separation also supports scoped or per-request stores when module-global state is unsafe in server-rendered applications. Do not assume middleware that changes the initializer's set or get also changes a vanilla store's direct getState and setState; that boundary is covered in Create a vanilla store.

Where to go next

Was this page helpful?

How Zustand works — zustand · GPT-5.6 Luna