Zustand: simple React state management
By Flavio Copes
Learn Zustand through a React store: actions, selectors, immutable updates, persistence, testing, SSR boundaries, and when local or server state is better.
Zustand is a small state-management library for React. The whole API fits in your head: a store, some actions, and selectors to read from it.
You create a store, put state and the functions that change it inside, then each component selects the piece it needs. No provider, no reducers, nothing else around a normal client-side store.
It’s for client-owned shared state. Think of a shopping cart, a multi-step wizard, playback controls, editor state, or preferences that distant components all read.
That doesn’t mean everything goes in a global store. A quick way to decide:
one component owns it -> useState
several components share it -> Zustand can help
the server owns it -> TanStack Query or a framework loader
If React state and rendering are still new to you, start with the free React course and come back.
Install Zustand
Install the package:
npm install zustand
The example for this post is a small cart. The store holds the items and exposes the actions that change them.
Create the first store
Create cart-store.js:
import { create } from 'zustand'
export const useCartStore = create(set => ({
items: [],
addItem: product => {
set(state => ({
items: [...state.items, product]
}))
},
clearCart: () => {
set({ items: [] })
}
}))
create() gives you back a React hook. The store API hangs off the same function, and we’ll use that later.
The callback you pass receives set. Calling set() changes the store and tells subscribed components to re-render.
When the new value depends on the current one, pass a function to set:
set(state => ({
items: [...state.items, product]
}))
When it doesn’t, pass the object directly:
set({ items: [] })
Either way, Zustand shallowly merges what you return into the current state. You don’t have to spread the rest of the store yourself.
Select only what the component needs
To read the item count, pass a selector to the hook:
import { useCartStore } from './cart-store.js'
export function CartCount() {
const count = useCartStore(state => state.items.length)
return <span>{count} items</span>
}
The component subscribes to the result of that selector. It re-renders when the count changes, not when anything else in the store changes.
Actions are selected the same way:
import { useCartStore } from './cart-store.js'
export function AddToCartButton({ product }) {
const addItem = useCartStore(state => state.addItem)
return (
<button onClick={() => addItem(product)}>
Add to cart
</button>
)
}
addItem normally keeps the same function reference across updates, so this button does not re-render when the cart changes. It only needs the action, and the action doesn’t change.
You can also call the hook with no selector:
const store = useCartStore()
Now the component subscribes to the whole store and re-renders on every change. Do that only if it really needs everything.
Build actions around the state
A component should say what it wants, addItem(product), and not know how the items array is structured.
Here is the cart with quantities:
export const useCartStore = create(set => ({
items: [],
addItem: product => {
set(state => {
const existing = state.items.find(
item => item.id === product.id
)
if (existing) {
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
}
return {
items: [
...state.items,
{ ...product, quantity: 1 }
]
}
})
},
removeItem: productId => {
set(state => ({
items: state.items.filter(item => item.id !== productId)
}))
},
clearCart: () => {
set({ items: [] })
}
}))
The UI still calls addItem(product) and removeItem(productId). The rule “same product twice means quantity 2” lives in one place, and if it changes, nothing in the components changes.
Update state immutably
Never mutate the objects and arrays inside the store.
This looks like it works:
set(state => {
state.items.push(product)
return state
})
This can fail because state.items is still the same array reference. A component selecting state.items can’t tell anything changed. Once you mutate it, you also can’t trust an old state object anymore.
Always build new arrays and objects:
set(state => ({
items: [...state.items, product]
}))
With deeply nested state this gets tedious, because you copy each level you touch. If that happens, first try flattening the store. If it still hurts, Zustand has an Immer middleware.
One nested object is not a good reason to add Immer. Most of the time, a flatter shape fixes the problem.
Derived values belong in selectors
Don’t store the total in the store. It can be computed from the items, and two stored values that should agree will drift apart at some point.
Write a selector instead:
const selectTotal = state =>
state.items.reduce(
(total, item) => total + item.price * item.quantity,
0
)
And use it:
function CartTotal() {
const total = useCartStore(selectTotal)
return <strong>€{(total / 100).toFixed(2)}</strong>
}
Prices in the store are integers, in cents. Formatting for display happens in the component.
Selecting more than one value
Sometimes a component wants two or three values at once. This is the obvious way to write it:
const cart = useCartStore(state => ({
count: state.items.length,
clearCart: state.clearCart
}))
The problem is that the selector returns a new object every time it runs. Same count, same function, but a fresh object, so the component re-renders on every store change.
useShallow fixes that by comparing the values inside the object:
import { useShallow } from 'zustand/react/shallow'
const cart = useCartStore(useShallow(state => ({
count: state.items.length,
clearCart: state.clearCart
})))
Note that it compares the top level only. Nested objects are still compared by reference.
My advice is to use separate selectors by default. Group with useShallow when it makes the component easier to read, not by habit.
Read and update outside React
The hook is also the store. You can read it from any JavaScript:
const cart = useCartStore.getState()
console.log(cart.items)
useCartStore.getState().clearCart()
And subscribe to changes without a component:
const unsubscribe = useCartStore.subscribe(state => {
console.log(state.items.length)
})
unsubscribe()
This is what you use to sync with a browser API, or in tests.
One warning about the server. A store defined at module level is created once per process, and on a server one process serves many users. State from user A must never leak into user B’s render. More on that below.
Persist selected state
The persist middleware saves the store to localStorage and restores it on the next load.
Here it saves only the items:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCartStore = create(
persist(
set => ({
items: [],
addItem: product => {
set(state => ({
items: [...state.items, product]
}))
},
clearCart: () => {
set({ items: [] })
}
}),
{
name: 'cart',
partialize: state => ({
items: state.items
})
}
)
)
name is the key in localStorage. partialize picks which part of the state gets written. Anything you leave out is not saved.
Never persist secrets, access tokens, or anything that decides what the user is allowed to do. localStorage is readable and writable by any script on the page, and by the user in devtools.
A persisted cart makes the site feel nicer after a refresh. It is not a source of truth. At checkout, the server still looks up current prices and stock.
Version persisted data
What you write to localStorage today is still there after next month’s deploy, in the old shape.
When the cart shape changes, bump the version and write a migration:
{
name: 'cart',
version: 1,
migrate: (state, version) => {
if (version === 0) {
return {
...state,
items: state.products ?? []
}
}
return state
}
}
Without it, users with old data get a component that reads state.items and finds undefined, days after you shipped and forgot about it.
Keep the persisted shape small. Every field you persist is a field you may have to migrate later.
Reset the store
You’ll want to reset the store on logout and between tests.
Keep the initial state in its own object so reset can reuse it:
const initialState = {
items: [],
couponCode: null
}
export const useCartStore = create(set => ({
...initialState,
reset: () => {
set(initialState)
}
}))
If the store is persisted, decide whether reset also clears storage. On logout it should. The next person to log in on that browser must not see the previous user’s cart.
Test the store without rendering React
Since the actions are plain functions, you can test the store with no React at all.
Reset before each test, then call the actions and read the state:
import { beforeEach, expect, test } from 'vitest'
import { useCartStore } from './cart-store.js'
beforeEach(() => {
useCartStore.setState({ items: [] })
})
test('adds a product', () => {
useCartStore.getState().addItem({
id: 'book',
price: 1900
})
expect(useCartStore.getState().items).toHaveLength(1)
})
Tests like this run in milliseconds. Save the component tests for rendering and clicks.
Zustand and server rendering
In a browser-only single-page app, one module-level store is fine. There is one user, one process, one store.
On a server that changes. One Node.js process handles requests from many users, and a module-level store is shared between all of them. That’s a bug in the best case and a data leak in the worst.
With an SSR framework:
- create a store per request
- initialize the browser store with the same data used on the server
- avoid reading or writing the store from React Server Components
- use a provider when you need a per-request store instance
The Zustand docs have a guide per framework, so follow the current one. A hydration bug shows up as an interface that flickers or changes on its own. Shared server state shows up as one user seeing another user’s data, which is much worse.
Zustand versus Context
React Context passes a value down a component tree. It’s a good fit for things that rarely change, like a theme, a locale, or a service object.
The catch is what happens on change. When a context value changes, every consumer can re-render. You can split contexts and memoize, but for shared state that changes often it takes work to keep fast.
Zustand gives each component its own selector subscription, so only the components that read the changed value re-render. And it works outside React too.
Use Context when the component tree is the right scope for the value. Use Zustand when a standalone store with fine-grained subscriptions describes the problem better.
Zustand versus Redux
Redux has stricter conventions, a big ecosystem, excellent devtools, and updates modeled as a stream of events. On a large team with complex workflows, those constraints help.
Zustand asks for much less. A working store fits in one file, no reducers, no provider.
Don’t pick based on which one has fewer lines. Pick the one your team will be able to debug in six months. Sometimes that’s Redux.
Zustand versus TanStack Query
Zustand is for client state. TanStack Query is for server state.
Copying fetched posts into a Zustand store to make them “global” means rebuilding caching, freshness, retries, and invalidation yourself. TanStack Query already does all of that.
Use TanStack Query for data from the API. Use Zustand for what the user is doing in the interface right now.
They live side by side in the same app:
TanStack Query -> products and account data
Zustand -> cart drawer, draft selections, editor mode
Common mistakes
One giant store. Unrelated state ends up coupled by accident. When two parts of the store stop changing together, split them.
Selecting the whole store. Every store change re-renders the component. Select the smallest value that does the job.
Mutating arrays and objects. Subscribers miss the change. Create new references.
Persisting everything. localStorage is not a free database. Persist only what should survive a reload.
Trusting persisted values. The user can edit them. Prices, permissions, and IDs get checked again on the server.
A module-level store during SSR. It’s shared between requests. Create one per request.
Moving state to the store too early. An input used by one form gains nothing from being global. Start with useState and move it only when a second, distant component needs it.
How I would use Zustand
Local state first, always. useState in the component that owns the value.
I would move a value into Zustand when distant components need it, when passing it through props turns into noise, or when code outside React has to read it.
The actions would carry the rules, and components would use small selectors. I would persist a handful of convenience values at most, and give the persisted shape a version from the first release, not after the first migration bug.
What I would not do is use Zustand as a second cache for server data, or as proof that a user can do something. The server owns the data and the permissions.
Want me to talk about your product? You can sponsor this site.