Why Zustand has taken over React state management
Redux was the default answer to global state for years, but its ceremony was
always a pain point. For every new feature you'd write an action type constant,
an action creator, a case in a reducer, and then wire up mapStateToProps or a
useSelector. Redux Toolkit trimmed that surface, but the mental model still
involves a separate store, slices, and the Redux DevTools ritual.
Zustand (German for "state") arrived with a radically simpler bet: what if the store were just a hook, and actions were just functions? The result is a library that weighs about 1 kB, ships no Provider, requires no boilerplate, and stays completely out of your way until you need advanced features.
As of 2025, Zustand is the most-downloaded React state management library after Redux Toolkit, appearing in interview questions at every level from junior to principal. Knowing it well means understanding not just the API, but why it makes the trade-offs it does.
Creating Your First Store
Everything starts with create(). You pass it a callback that receives set
(and optionally get) and returns an object containing both state fields and
the actions that mutate them — there's no separate reducers file.
import { create } from 'zustand'
const useBearStore = create((set, get) => ({
// ── State ──────────────────────────────────────────────────────────────
bears: 0,
honey: 100,
// ── Actions ────────────────────────────────────────────────────────────
// Updater function form — use when new value depends on current value
addBear: () => set((state) => ({ bears: state.bears + 1 })),
// Partial object form — fine when value is independent
removeAllBears: () => set({ bears: 0 }),
// get() reads current state without subscribing to it
eatHoney: (amount) => {
const { honey } = get()
if (honey >= amount) set({ honey: honey - amount })
},
}))
set does a shallow merge — you only include the keys you want to change,
similar to setState in class components. Pass true as the second argument
(set(fn, true)) when you want a full replacement instead.
Consuming the store is a single hook call:
function Bears() {
const bears = useBearStore((s) => s.bears)
const addBear = useBearStore((s) => s.addBear)
return <button onClick={addBear}>{bears} bears</button>
}
No Provider wraps Bears. Zustand stores state outside the React tree, so
any component anywhere in the app can subscribe.
Selectors and Re-render Optimization
The function you pass to useBearStore() is a selector. It determines what
the component subscribes to. Zustand compares the selector's return value
between renders using Object.is — if it's unchanged, the component doesn't
re-render.
// ❌ No selector — subscribes to the entire store object.
// Re-renders whenever bears OR honey changes.
const state = useBearStore()
const bears = state.bears
// ✅ Selector — subscribes only to bears.
// Re-renders only when bears changes.
const bears = useBearStore((s) => s.bears)
Object.is works correctly for primitives and stable object references but
fails when your selector constructs a new object or array on every call.
That's where shallow from zustand/shallow comes in:
import { shallow } from 'zustand/shallow'
// Without shallow: new object literal every call → always re-renders
const { bears, honey } = useBearStore((s) => ({ bears: s.bears, honey: s.honey }))
// With shallow: compares each key individually → only re-renders on real changes
const { bears, honey } = useBearStore(
(s) => ({ bears: s.bears, honey: s.honey }),
shallow,
)
The rule is simple: use shallow whenever your selector returns an object or
array; skip it when it returns a single value.
Async Actions
This is one of the most-asked interview topics because Zustand's answer is
surprisingly elegant: you don't need middleware. Actions are plain
functions, so they can be async. Call set as many times as you like inside
the function — once for loading, once for success, once for error.
const usePostsStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null }) // 1. show spinner
try {
const res = await fetch('/api/posts')
const data = await res.json()
set({ posts: data, loading: false }) // 2. success
} catch (err) {
set({ error: err.message, loading: false }) // 3. failure
}
},
}))
Consuming it is as natural as any other action:
function PostList() {
const { posts, loading, fetchPosts } = usePostsStore()
useEffect(() => {
fetchPosts()
}, [fetchPosts]) // fetchPosts has a stable identity — safe in deps
if (loading) return <p>Loading…</p>
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
Compare this to Redux, where async work requires installing redux-thunk (or
redux-saga) and following a specific action-dispatching protocol. Zustand's
async story requires zero extra packages.
Useful Middleware
Zustand ships three middleware utilities that cover the most common production
needs. Each is a wrapper — you compose them around your create() callback.
devtools — Redux DevTools integration
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
// Third argument to set names the action in the DevTools log
increment: () => set((s) => ({ count: s.count + 1 }), false, 'counter/increment'),
}),
{ name: 'AppStore', enabled: process.env.NODE_ENV !== 'production' },
),
)
With devtools you get time-travel debugging, action logs, and state diffing
in the Redux DevTools browser extension — the same tools Redux users rely on,
at no extra cost.
persist — localStorage / sessionStorage hydration
import { persist, createJSONStorage } from 'zustand/middleware'
const useSettingsStore = create(
persist(
(set) => ({
theme: 'dark',
fontSize: 16,
setTheme: (t) => set({ theme: t }),
}),
{
name: 'user-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme }), // only persist theme
},
),
)
partialize is important: never persist loading flags, error messages, or
other transient state. On the next page load they'd render stale UI before the
first fetch completes.
immer — mutable-style updates
import { immer } from 'zustand/middleware/immer'
const useCartStore = create(
immer((set) => ({
items: [],
// Direct mutation — Immer converts it to a structural share
addItem: (item) => set((state) => {
state.items.push(item)
}),
updateQty: (id, qty) => set((state) => {
const item = state.items.find(i => i.id === id)
if (item) item.qty = qty // nested mutation — no spreads needed
}),
})),
)
Use immer for deeply nested state where manual spreading becomes brittle.
Skip it for flat state — the overhead isn't worth it.
Zustand vs Redux Toolkit vs Context
Interviewers often ask you to compare the three. Here's the honest picture:
| Context | Zustand | Redux Toolkit | |
|---|---|---|---|
| Bundle size | 0 kB (built-in) | ~1 kB | ~12 kB |
| Provider required | Yes | No | Yes |
| Re-render granularity | Per-context | Per-selector | Per-selector |
| Devtools | None | Via middleware | First-class |
| Async | Manual | Plain async/await | createAsyncThunk / RTK Query |
| Data fetching + caching | None | DIY or pair with TanStack | RTK Query |
| Learning curve | Low | Very low | Medium |
When to use Context: Infrequently-changing cross-cutting concerns — theme, locale, current user identity. It's built into React, requires no dependencies, and the performance hit only matters when the value changes constantly.
When to use Zustand: Multiple unrelated components share frequently-updated state. You want global state with minimal boilerplate, a small bundle, and access outside the component tree. Medium-to-large apps where Context's all-consumers-re-render model would be a problem.
When to use Redux Toolkit: Very large apps with large teams that benefit from strict conventions and code organization. RTK Query is a compelling data fetching + caching layer — if you'd otherwise reach for TanStack Query plus Zustand, RTK Query's integration can unify the two concerns.
There's no "wrong" answer — a well-reasoned trade-off explanation is exactly what interviewers want to hear.
Interview Tips
Know the "no Provider" detail. Interviewers often ask how Zustand avoids
the Provider requirement. The answer is that it stores state in a closure
outside the React tree, using React's useSyncExternalStore (or a custom
subscription internally) to connect components to that external store.
Explain selectors and shallow confidently. This is the most common
follow-up question at mid to senior level. Be able to explain why Object.is
fails for object selectors and when shallow fixes it.
Don't dismiss Redux. Saying "Zustand is better" signals inexperience. Instead, articulate the trade-offs: Zustand for smaller bundles and less ceremony, Redux Toolkit for large teams, strict conventions, and RTK Query.
Know the slices pattern. If asked how you'd scale a Zustand store, describe
splitting state into slice files (createAuthSlice, createCartSlice) and
merging them in a single create(). This shows you've thought past toy-app
usage.
Be able to write a store from memory. A create() call with state, an
action that uses the updater function form, and a selector in a component —
that's the core pattern. Practice writing it without looking it up.
Zustand is a small library with a handful of concepts, but demonstrating you understand why it makes each design choice — no Provider, selector-based subscriptions, middleware composition — is what separates a candidate who Googled the API from one who has actually used it in production.