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:
bashnpm install zustand react
mermaidflowchart 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.
tsximport { 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.
tsximport { 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.
tsximport { 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
- Build a React store for a complete application store.
- Immutable state updates for nested update patterns.
- Selectors and rendering for selector stability and equality.
- Create a vanilla store for non-React and scoped stores.
- Handle server-rendered stores for per-request state and server-rendered applications.
Was this page helpful?