Zustand (German for "state") is a lightweight React state-management library built on a flux-inspired one-way data flow but with almost zero boilerplate. It stores state outside the React tree in a plain JavaScript object and lets any component subscribe to it without requiring a Provider wrapper.
The problem it solves: Context re-renders every consumer when any part
of the context value changes, and Redux forces you to write reducers,
action types, action creators, and selectors just to add a counter.
Zustand replaces all of that with a single create() call.
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0, // state
increment: () => set((s) => ({ count: s.count + 1 })), // action
reset: () => set({ count: 0 }),
}))
// Consume in any component — no Provider needed
function Counter() {
const count = useCounterStore((s) => s.count)
const increment = useCounterStore((s) => s.increment)
return <button onClick={increment}>{count}</button>
}
The store is a React hook, state is mutable through set, and
components only re-render when the slice they selected changes.
Rule of thumb: Reach for Zustand when Context performance starts hurting or when you need global state that isn't tied to a single component subtree.
create() accepts a callback that receives set (and optionally
get) and returns the initial state object. State properties and
actions (functions that update state) live in the same object — there
are no separate reducers or action types.
import { create } from 'zustand'
const useBearStore = create((set, get) => ({
bears: 0,
honey: 100,
// Action using updater function — safe when next value depends on current
addBear: () => set((state) => ({ bears: state.bears + 1 })),
// Action using partial object — fine when value is independent
removeAllBears: () => set({ bears: 0 }),
// Action reading current state via get()
eatHoney: (amount) => {
const { honey } = get() // read without subscribing
if (honey >= amount) set({ honey: honey - amount })
},
}))
set does a shallow merge (like setState in class components),
so you only specify the keys you want to update. Pass true as the
second argument (set(fn, true)) to replace rather than merge.
Rule of thumb: Put both state fields and their mutation functions
in one create() call — it keeps related data and logic together and
avoids cross-file action imports.
Without a selector the component re-renders on every store update, even changes unrelated to what it displays. A selector is a function passed to the store hook that picks only the slice the component needs; Zustand compares the return value between renders and skips the re-render if it hasn't changed.
const useBearStore = create(() => ({ bears: 0, honey: 100 }))
// ❌ No selector — subscribes to the whole store object.
// Re-renders whenever bears OR honey changes.
function BearsDisplay() {
const state = useBearStore() // returns { bears, honey }
return <span>{state.bears}</span>
}
// ✅ Selector — subscribes only to bears.
// Re-renders only when bears changes.
function BearsDisplay() {
const bears = useBearStore((s) => s.bears)
return <span>{bears}</span>
}
Zustand uses Object.is equality by default. For selectors that return
objects or arrays, swap in shallow from zustand/shallow so that
a new object with the same keys doesn't trigger a re-render.
Rule of thumb: Always select the smallest slice you need, just as
you would with a Redux useSelector.
The idiomatic Zustand approach is to define actions inside the
create() callback alongside the state they mutate. This co-location
means actions can call set and get directly without being passed as
arguments, and consumers import a single hook instead of separate
action creators.
const useUserStore = create((set, get) => ({
user: null,
isLoading: false,
// Action defined inside — has closure access to set and get
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
// Read other state fields via get() inside an action
isAdmin: () => get().user?.role === 'admin',
}))
// Consumers just call the action directly
function LoginButton() {
const setUser = useUserStore((s) => s.setUser)
return <button onClick={() => setUser({ id: 1, role: 'admin' })}>
Login
</button>
}
The alternative — defining actions outside via useUserStore.setState —
works but scatters logic and is usually only worthwhile in very large
stores where the slices pattern is used.
Rule of thumb: Keep actions inside create() unless your store
grows so large that the slices pattern becomes necessary.
Zustand requires no special middleware for async work. Because actions
are plain functions, you can use async/await directly. Call set
once or multiple times inside the async function to reflect loading,
success, and error states.
const usePostsStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null }) // show spinner
try {
const res = await fetch('/api/posts')
const data = await res.json()
set({ posts: data, loading: false }) // success
} catch (err) {
set({ error: err.message, loading: false }) // failure
}
},
}))
function PostList() {
const { posts, loading, fetchPosts } = usePostsStore()
// fetchPosts is a stable reference — safe in useEffect deps
useEffect(() => { fetchPosts() }, [fetchPosts])
if (loading) return <p>Loading…</p>
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
This is one of Zustand's biggest DX wins over Redux, where you need
redux-thunk or redux-saga to do the same thing.
Rule of thumb: Write async actions exactly like any async function
— no middleware, no special patterns, just await and set.
Zustand's default comparison is Object.is — fine for primitives and
stable references, but it fails when a selector returns a new object
or array on every call. Import shallow from zustand/shallow and
pass it as the second argument to suppress spurious re-renders.
import { shallow } from 'zustand/shallow'
const useStore = create(() => ({
name: 'Alice',
age: 30,
theme: 'dark',
}))
// ❌ Without shallow — new object every render → always re-renders
function Profile() {
const { name, age } = useStore((s) => ({ name: s.name, age: s.age }))
return <p>{name}, {age}</p>
}
// ✅ With shallow — compares keys one level deep → re-renders only
// when name or age actually changes
function Profile() {
const { name, age } = useStore(
(s) => ({ name: s.name, age: s.age }),
shallow, // second argument
)
return <p>{name}, {age}</p>
}
// Also works for picking multiple fields as an array
const [name, age] = useStore((s) => [s.name, s.age], shallow)
Rule of thumb: Use shallow whenever your selector returns an
object literal or array; skip it when the selector returns a single
primitive or stable reference.
Wrap the create() callback with the devtools middleware from
zustand/middleware. Once connected, every set call appears as a
named action in the Redux DevTools extension, and you get time-travel
debugging for free.
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useCounterStore = create(
devtools(
(set) => ({
count: 0,
// Name each action for the DevTools action log
increment: () => set(
(s) => ({ count: s.count + 1 }),
false, // don't replace — merge
'counter/increment', // action name shown in DevTools
),
reset: () => set({ count: 0 }, false, 'counter/reset'),
}),
{ name: 'CounterStore' }, // store name shown in DevTools
),
)
You can pass { enabled: process.env.NODE_ENV === 'development' } to
the devtools options to strip it from production bundles.
Rule of thumb: Add devtools early in development — the action
naming discipline pays dividends when debugging complex state flows.
The persist middleware from zustand/middleware serializes store
state to a storage engine (localStorage by default) after every set
and rehydrates it on page load. You wrap your store definition exactly
like devtools.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useSettingsStore = create(
persist(
(set) => ({
theme: 'dark',
fontSize: 16,
setTheme: (t) => set({ theme: t }),
setFontSize: (s) => set({ fontSize: s }),
}),
{
name: 'user-settings', // localStorage key
storage: createJSONStorage(() => localStorage), // default
// Persist only a subset of state
partialize: (state) => ({ theme: state.theme }),
},
),
)
For SSR / Next.js, swap to sessionStorage or a custom storage
adapter so hydration mismatches are avoided. partialize lets you
exclude sensitive or derived fields (like loading flags) from the
persisted snapshot.
Rule of thumb: Always use partialize to be explicit about which
fields survive a page reload — persisting loading or error flags causes
stale UI on startup.
By default, set in Zustand requires you to return a new partial
object. Wrapping with the immer middleware lets you write
mutations directly on a draft — Immer produces the new immutable state
behind the scenes.
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useCartStore = create(
immer((set) => ({
items: [],
// Without immer you'd write: set(s => ({ items: [...s.items, item] }))
addItem: (item) => set((state) => {
state.items.push(item) // direct mutation — Immer handles it
}),
removeItem: (id) => set((state) => {
state.items = state.items.filter(i => i.id !== id)
}),
updateQty: (id, qty) => set((state) => {
const item = state.items.find(i => i.id === id)
if (item) item.qty = qty // nested mutation — safe with Immer
}),
})),
)
Without immer, updating nested objects requires spreading every layer
manually. With it you write imperative mutations and Immer converts them
to structural shares.
Rule of thumb: Add immer when your state has deeply nested
structures; skip it for flat state where spread syntax is readable
enough.
As a store grows, cramming all state and actions into one create() call
becomes unwieldy. The slices pattern splits the store into separate
functions (slices), each responsible for a domain, and merges them in a
single create() call.
// slices/authSlice.js
export const createAuthSlice = (set) => ({
user: null,
login: (u) => set({ user: u }),
logout: () => set({ user: null }),
})
// slices/cartSlice.js
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clearCart: () => set({ items: [] }),
})
// store.js — combine slices into one store
import { create } from 'zustand'
import { createAuthSlice } from './slices/authSlice'
import { createCartSlice } from './slices/cartSlice'
export const useStore = create((...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
}))
// Consumers select from the unified store as usual
const user = useStore((s) => s.user)
const items = useStore((s) => s.items)
This is the Zustand-recommended approach for large stores. Each slice file stays focused and testable independently.
Rule of thumb: Introduce slices once your store exceeds ~5 concerns or the single file becomes hard to navigate.
The store object created by create() is also a vanilla store with a
.subscribe() method. You can call it in plain JS modules, Node.js
scripts, or outside the component tree — no hooks needed.
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// Subscribe outside React
const unsub = useStore.subscribe(
(state) => state.count, // selector — called only when count changes
(count, prevCount) => {
console.log(`count changed: ${prevCount} → ${count}`)
if (count >= 10) analytics.track('milestone_10')
},
)
// Read state without subscribing
const current = useStore.getState().count
// Set state without a component
useStore.setState({ count: 0 })
// Clean up when done
unsub()
This is useful for analytics side effects, syncing with non-React systems, or writing integration tests that assert state without rendering.
Rule of thumb: Use .subscribe() / .getState() / .setState()
for interactions with the store that live outside the React lifecycle.
Both libraries manage global state in a React app, but they occupy different points on the complexity/power spectrum.
| Concern | Zustand | Redux Toolkit |
|---|---|---|
| Bundle size | ~1 kB | ~12 kB |
| Boilerplate | Minimal (create + actions) |
Slices + configure + reducers |
| Devtools | Yes (via middleware) | First-class, built-in |
| Async | Plain async functions | createAsyncThunk / RTK Query |
| Data fetching | DIY or Zustand + SWR/TanStack | RTK Query (cache, dedup) |
| Middleware ecosystem | Small (devtools, persist, immer) | Large (mature) |
| TypeScript | Good | Excellent |
| Learning curve | Low | Medium-High |
// Same counter — Zustand vs RTK
// Zustand (6 lines)
const useCounter = create((set) => ({
value: 0,
increment: () => set((s) => ({ value: s.value + 1 })),
}))
// Redux Toolkit (25+ lines)
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: { increment: (state) => { state.value++ } },
})
const store = configureStore({ reducer: { counter: counterSlice.reducer } })
Choose Redux Toolkit when: you have a large team that benefits from strict conventions, you need RTK Query for server-cache management, or you're already invested in the Redux ecosystem. Choose Zustand when: you want quick setup, smaller bundle, or a library that stays out of your way.
Rule of thumb: Zustand for small-to-medium apps; Redux Toolkit when the app is large, team is big, or RTK Query's server-state caching is worth the extra weight.
Context and Zustand solve the same problem — sharing state across the tree — but with very different performance models.
// Context: every consumer re-renders when ANY part of value changes
const AppCtx = createContext(null)
function AppProvider({ children }) {
const [count, setCount] = useState(0)
const [user, setUser ] = useState(null)
// ❌ Changing count re-renders components that only read user
return <AppCtx.Provider value={{ count, setCount, user, setUser }}>
{children}
</AppCtx.Provider>
}
// Zustand: each component subscribes only to the slice it selects
const useAppStore = create((set) => ({
count: 0, setCount: (n) => set({ count: n }),
user: null, setUser: (u) => set({ user: u }),
}))
// This component NEVER re-renders when count changes
function UserMenu() {
const user = useAppStore((s) => s.user)
return <span>{user?.name}</span>
}
Additional differences:
- Provider: Context requires a Provider in the tree; Zustand needs none.
- Outside React: Zustand is accessible without hooks; Context is not.
- DevTools: Zustand supports the Redux DevTools extension; Context has no built-in equivalent.
- Bundle overhead: Context adds zero bytes; Zustand adds ~1 kB.
Rule of thumb: Use Context for infrequently-changing cross-cutting concerns (theme, locale, auth token); switch to Zustand when multiple components subscribe to frequently-changing shared state.
Zustand stores are plain JS objects — you can call actions directly and assert state without rendering any components.
// store.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from './cartStore'
// Reset store state between tests to prevent leakage
beforeEach(() => {
useCartStore.setState({ items: [] }) // reset to initial
})
describe('cart store', () => {
it('adds an item', () => {
useCartStore.getState().addItem({ id: 1, name: 'Book', qty: 1 })
expect(useCartStore.getState().items).toHaveLength(1)
})
it('removes an item', () => {
useCartStore.setState({ items: [{ id: 1, name: 'Book', qty: 1 }] })
useCartStore.getState().removeItem(1)
expect(useCartStore.getState().items).toHaveLength(0)
})
})
For component tests, render normally with @testing-library/react —
no mocking needed. If you want to test a component in isolation, seed
the store with setState before rendering and reset with setState
in beforeEach.
Rule of thumb: Test stores directly via .getState() / .setState()
for unit tests; use the real store (no mocks) for component integration
tests.
Define an interface (or type) for the store shape and pass it as the
generic type argument to create. Zustand infers the rest automatically.
import { create } from 'zustand'
// 1. Define the shape of state + actions
interface BearState {
bears: number
honey: number
addBear: () => void
eatHoney: (amount: number) => void
}
// 2. Pass the type to create — the callback parameter is typed
const useBearStore = create<BearState>()((set, get) => ({
bears: 0,
honey: 100,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
eatHoney: (amount) => {
const { honey } = get()
if (honey >= amount) set({ honey: honey - amount })
},
}))
// Selectors are fully typed — TypeScript knows bears is number
const bears: number = useBearStore((s) => s.bears)
Note the extra () after create<BearState>() — this is a TypeScript
currying workaround required to preserve type inference on the callback
without explicitly annotating set.
Rule of thumb: Always define the store interface first; it acts as living documentation of every field and action the store owns.
Component-local state (useState / useReducer) is the right
default. Lift to Zustand only when one of these conditions holds:
- Multiple unrelated components need to read or mutate the same state (avoids prop drilling or a Context that's too coarse).
- The state outlives the component (e.g., persisted cart that should survive route changes).
- You need to read or update state outside React (analytics, WebSocket handlers, service workers).
- Performance: the state changes frequently and many components depend on it — Context would cause a render cascade.
// ✅ Local state — belongs to one component
function SearchInput() {
const [query, setQuery] = useState('')
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
// ✅ Zustand — notifications consumed by Navbar, Toast, and a WebSocket
const useNotifStore = create((set) => ({
notifs: [],
add: (n) => set((s) => ({ notifs: [n, ...s.notifs] })),
clear: () => set({ notifs: [] }),
}))
Rule of thumb: Default to useState; reach for Zustand when state
needs to be shared across component boundaries or accessed outside React.
Keep a reference to the initial state and call set with it in a
reset action. Because set does a shallow merge by default, passing
true as the second argument does a full replace, which avoids stale
keys if the store shape has changed.
import { create } from 'zustand'
// Capture initial state outside create() so it's always available
const initialState = {
user: null,
cart: [],
filters: { sort: 'newest', page: 1 },
}
const useStore = create((set) => ({
...initialState,
setUser: (u) => set({ user: u }),
addToCart: (item) => set((s) => ({ cart: [...s.cart, item] })),
// Pass true to replace — ensures every key goes back to initial
resetAll: () => set(initialState, true),
// Or reset only a slice
clearCart: () => set({ cart: [] }),
}))
// Called at logout
function handleLogout() {
useStore.getState().resetAll()
router.push('/login')
}
This pattern is also the standard way to reset stores in tests:
useStore.setState(initialState, true) in a beforeEach.
Rule of thumb: Always store initialState in a const above
create() so resets stay in sync with the store's definition.
Zustand has no built-in "computed" concept. The two idiomatic approaches
are inline selector logic in useStore() (for simple derivations)
and memoized selectors via a library like reselect or a plain
useMemo (for expensive ones).
const useCartStore = create(() => ({
items: [
{ id: 1, name: 'Book', price: 12, qty: 2 },
{ id: 2, name: 'Shirt', price: 30, qty: 1 },
],
}))
// ─── Option 1: derive inline in the selector (simple, zero extra deps) ───
function CartTotal() {
const total = useCartStore((s) =>
s.items.reduce((sum, item) => sum + item.price * item.qty, 0)
)
return <strong>Total: ${total}</strong>
}
// Re-renders only when items changes; computation runs on every render
// ─── Option 2: store a selector factory (avoid recompute if items unchanged)
import { useMemo } from 'react'
function CartSummary() {
const items = useCartStore((s) => s.items)
const total = useMemo(
() => items.reduce((sum, i) => sum + i.price * i.qty, 0),
[items], // only recalculates when items reference changes
)
return <p>{items.length} items — ${total}</p>
}
Storing derived values inside the store itself is an anti-pattern — they can get out of sync if the source data is updated directly.
Rule of thumb: Keep derived values out of the store; compute them
in selectors or useMemo so they're always consistent with their
source.
More State Management interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.