Zustand keeps state immutable: update through the store API, and return new references for the parts that changed. The default update is a shallow merge; nested values and complete-state replacement are explicit choices.
How the update works
The create function gives a React component a bound store hook. Its initializer receives set, which accepts a partial object or an updater function. Zustand compares the updater result with the current state using Object.is; when the result differs, it either shallowly merges the object or replaces the state.
mermaidflowchart LR A["set(updater)"] --> B["next state"] B --> C{"Object.is(next, current)?"} C -->|yes| D["no notification"] C -->|no| E{"replace = true?"} E -->|no| F["shallow merge one level"] E -->|yes| G["replace complete state"] F --> H["notify subscribers"] G --> H
The reference check is about the state value returned by the updater. For an object nested inside that state, mutate-in-place can leave the nested reference unchanged, so return a new nested object instead.
Shallow updates
Use a partial object for a flat update. The other top-level properties remain in the store:
tsximport { create } from 'zustand'
type PersonState = {
firstName: string
lastName: string
updateFirstName: (firstName: string) => void
}
const usePersonStore = create<PersonState>()((set) => ({
firstName: 'Ada',
lastName: 'Lovelace',
updateFirstName: (firstName) => set({ firstName }),
}))
export function PersonEditor() {
const firstName = usePersonStore((state) => state.firstName)
const lastName = usePersonStore((state) => state.lastName)
const updateFirstName = usePersonStore((state) => state.updateFirstName)
return (
<main>
<label>
First name
<input
value={firstName}
onChange={(event) => updateFirstName(event.currentTarget.value)}
/>
</label>
<p>{firstName} {lastName}</p>
</main>
)
}
Typing in the input updates firstName while lastName stays in the state. The component selects each value separately, so each selected primitive changes only when that value changes.
Nested updates
The merge stops at the first level. Preserve each object on the path to the field you change:
tsximport { create } from 'zustand'
type Settings = {
theme: string
density: 'comfortable' | 'compact'
}
type SettingsState = {
settings: Settings
setTheme: (theme: string) => void
}
const useSettingsStore = create<SettingsState>()((set) => ({
settings: { theme: 'light', density: 'comfortable' },
setTheme: (theme) =>
set((state) => ({
settings: { ...state.settings, theme },
})),
}))
export function SettingsPanel() {
const settings = useSettingsStore((state) => state.settings)
const setTheme = useSettingsStore((state) => state.setTheme)
return (
<section>
<p>Theme: {settings.theme}</p>
<p>Density: {settings.density}</p>
<button type="button" onClick={() => setTheme('dark')}>
Use dark theme
</button>
</section>
)
}
Clicking the button changes theme and keeps density. Omitting ...state.settings would create a new settings object without density, because the default merge does not recursively merge nested objects. For deeper structures, copy every changed level, or use the Immer update guide.
Replacing the complete state
Pass true as the second argument to set when the returned value is the complete state model. Replacement also removes properties that are not in the new value, including actions in a store that defines actions in its state.
This standalone example uses createStore from the zustand/vanilla package and binds it to React with useStore:
tsximport { createStore } from 'zustand/vanilla'
import { useStore } from 'zustand'
type NoticeState = {
message: string
visible: boolean
}
const noticeStore = createStore<NoticeState>(() => ({
message: 'Saved',
visible: true,
}))
export function Notice() {
const notice = useStore(noticeStore)
return (
<section>
<p>{notice.visible ? notice.message : 'No notice'}</p>
<button
type="button"
onClick={() =>
noticeStore.setState(
{ message: 'Archived', visible: false },
true,
)
}
>
Archive
</button>
</section>
)
}
Before the click, the component shows Saved. After the click, replacement changes the complete NoticeState value and the component shows No notice. Use replacement only when you have every property required by the state type.
Collections and references
Map and Set are mutable objects, so create a new instance when changing one. The new instance gives the selected value a new reference:
tsimport { createStore } from 'zustand/vanilla'
type TagState = {
tags: Set<string>
}
const tagStore = createStore<TagState>(() => ({ tags: new Set<string>() }))
const addTag = (tag: string) => {
tagStore.setState((state) => ({
tags: new Set(state.tags).add(tag),
}))
}
Do not mutate state.tags and return the same Set. Its reference remains equal, so a selector of tags does not observe a changed reference. Type empty collections explicitly when TypeScript cannot infer their element types.
Next steps
- Selectors and rendering explains how selected references affect re-renders.
- Update nested state with Immer covers the middleware and its pitfalls.
- Create a vanilla store develops the standalone-store pattern.
Was this page helpful?