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.
You create a store, put state and actions inside it, then select the pieces each component needs. No provider is required for a normal client-side store.
Use it for client-owned shared state: a shopping cart, a multi-step wizard, playback controls, editor state, or preferences used across distant components.
Do not put every value in a global store.
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 new to you, start with the free React course.
Install Zustand
Install the package:
npm install zustand
We will build a small cart. The store will hold items and expose 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() returns a React hook with a store API attached.
The callback receives set. Calling set() updates the store and notifies subscribed components.
When the new value depends on current state, pass an updater function:
set(state => ({
items: [...state.items, product]
}))
For an independent value, pass an object:
set({ items: [] })
By default, Zustand shallowly merges the returned object into the current store.
Select only what the component needs
Read the item count with a selector:
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 selector result. It re-renders when that result changes.
Select the action in another component:
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>
)
}
The action function normally keeps the same reference, so cart updates do not cause this component to re-render through the store subscription.
Avoid this unless the component needs the complete store:
const store = useCartStore()
That component subscribes to every store change.
Build actions around the state
Components should express intent. They should not know how every store update works.
Add quantity-aware actions:
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: [] })
}
}))
Now the UI calls addItem(product) and removeItem(productId). The cart rules stay in one place.
Update state immutably
Treat objects and arrays in the store as immutable.
This is wrong:
set(state => {
state.items.push(product)
return state
})
The same object references can prevent subscriptions from seeing the change. Mutation also makes state history harder to reason about.
Create new arrays and objects instead:
set(state => ({
items: [...state.items, product]
}))
For deeply nested state, you must copy each changed level. If that becomes painful, flatten the store or use Zustand’s Immer middleware.
Do not add Immer for one small nested object. A simpler state shape is often the better fix.
Derived values belong in selectors
Do not store a total that can be calculated from items. Two stored values can drift apart.
Create a selector:
const selectTotal = state =>
state.items.reduce(
(total, item) => total + item.price * item.quantity,
0
)
Use it in a component:
function CartTotal() {
const total = useCartStore(selectTotal)
return <strong>€{(total / 100).toFixed(2)}</strong>
}
The store keeps integer minor units such as cents. The component formats them for display.
Selecting more than one value
This selector creates a new object on every call:
const cart = useCartStore(state => ({
count: state.items.length,
clearCart: state.clearCart
}))
Even when the values inside stay the same, the object reference is new.
Use useShallow when you want a small object or array of top-level values:
import { useShallow } from 'zustand/react/shallow'
const cart = useCartStore(useShallow(state => ({
count: state.items.length,
clearCart: state.clearCart
})))
useShallow compares the top-level values. It does not deeply compare nested objects.
My advice is to start with separate selectors. Reach for useShallow when grouping values makes the component clearer.
Read and update outside React
The hook also exposes store methods:
const cart = useCartStore.getState()
console.log(cart.items)
useCartStore.getState().clearCart()
You can subscribe without rendering a component:
const unsubscribe = useCartStore.subscribe(state => {
console.log(state.items.length)
})
unsubscribe()
This is useful for browser integrations and tests.
Be careful on the server. A module-level store can be shared between requests. User A’s state must never appear in user B’s render.
Persist selected state
Zustand’s persist middleware can save store state in localStorage.
Persist only the cart 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 storage key. partialize chooses what is saved.
Do not store secrets, access tokens, or trusted authorization state in localStorage. Users and scripts on the page can read and change it.
A persisted cart is a convenience. The server must still look up current prices and availability during checkout.
Version persisted data
Stored state can outlive your code deployment.
If the cart shape changes, add a version and migration:
{
name: 'cart',
version: 1,
migrate: (state, version) => {
if (version === 0) {
return {
...state,
items: state.products ?? []
}
}
return state
}
}
Without a migration, old browser data can break a new component days after deployment.
Keep persisted shapes small. Every stored field becomes data you may need to migrate.
Reset the store
Logout and test cleanup often need a complete reset.
Keep the initial state in one value:
const initialState = {
items: [],
couponCode: null
}
export const useCartStore = create(set => ({
...initialState,
reset: () => {
set(initialState)
}
}))
If the store is persisted, decide whether reset should also clear storage. Logging out should not leave another user’s private client state behind.
Test the store without rendering React
Actions are plain functions on the store API.
Reset before each test:
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)
})
Focused store tests are fast. Keep component tests for rendering and user interaction.
Zustand and server rendering
A browser-only single-page application can use one module-level store.
Server rendering changes the rules. A server handles many users in one process, so a global mutable store can leak state between requests.
For SSR frameworks:
- 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
Follow the current Zustand guide for your framework. Hydration bugs can look like random interface changes, while shared server state is a security problem.
Zustand versus Context
React Context passes values through a component tree. It is excellent for stable dependencies such as a theme, locale, or service object.
When a context value changes, every consumer of that context can re-render. You can split contexts and memoize values, but frequent shared state needs care.
Zustand gives each component a selector subscription. It also works outside React.
Use Context when the component tree is the natural scope. Use Zustand when an independent store and fine-grained subscriptions make the model clearer.
Zustand versus Redux
Redux has stricter conventions, a large ecosystem, excellent devtools, and predictable event-style updates. Those qualities help large teams and complex workflows.
Zustand has less ceremony. You can build a useful store in one file without reducers or a provider.
Do not choose only by line count. Choose the state model your team can debug six months later.
Zustand versus TanStack Query
Zustand manages client state. TanStack Query manages server state.
Do not copy fetched posts into Zustand just to make them global. You would have to rebuild caching, freshness, retries, and invalidation.
Use TanStack Query for API data. Use Zustand for what the user is doing in the interface.
They can live in the same application:
TanStack Query -> products and account data
Zustand -> cart drawer, draft selections, editor mode
Common mistakes
One giant store
Unrelated state creates accidental coupling. Split stores by feature or lifecycle when they stop changing together.
Selecting the whole store
The component re-renders for unrelated changes. Select the smallest useful value.
Mutating arrays and objects
Create new references for changed state.
Persisting everything
Storage is not free state management. Persist only values that should survive a reload.
Trusting persisted values
Anything in the browser can be changed. Revalidate prices, permissions, and identifiers on the server.
Using a global store during SSR
Create stores per request. Never let request-specific data live in shared module state.
Moving local state too early
An input used by one form does not become better because it lives globally. Start with useState.
How I would use Zustand
I would start with local React state.
I would move a value to Zustand when distant components need it, prop passing becomes noise, or non-React code needs access to the same state.
I would keep actions close to the data rules and use small selectors in components. I would persist only a few convenience values, with a version from the first release.
I would not use Zustand as a second database cache or as proof that a user can perform an action. The server still owns durable data and authorization.
Zustand is useful because the model stays small: state, actions, selectors. Keep it that way.
Want me to talk about your product? You can sponsor this site.