Zustand: Lightweight State Management for React
State management has long been one of the most debated topics in React development. For years, Redux was the default answer — powerful but notorious for its boilerplate, actions, reducers, and middleware chains. Then came the Context API, which solved smaller problems but caused performance headaches at scale. Zustand changes this conversation entirely: it's a tiny (~1KB), fast, and surprisingly flexible state management library that lets you manage global state with almost no ceremony.
Released by the team behind React Spring, Zustand (German for "state") has grown to over 45,000 GitHub stars and is the preferred choice for thousands of React projects. This guide covers everything from your first store to production patterns like middleware, persistence, and TypeScript integration.
Zustand's entire bundle is ~1KB gzipped — compare that to Redux Toolkit at ~40KB. It needs no providers wrapping your app, no boilerplate actions or reducers, and re-renders only the components that actually use the piece of state that changed.
What is Zustand?
Zustand is a minimal, unopinionated state management library built on top of React hooks. Its API surface is tiny: you call create() to define a store, and then call the resulting hook in any component that needs the state. That's essentially it.
What makes Zustand uniquely powerful is its design philosophy — it avoids the complexity traps that plague most state management solutions:
- No Provider required: Unlike Context API or Redux, you don't need to wrap your component tree in a
<Provider>. Stores are module-level singletons. - Selector-based subscriptions: Components only re-render when the specific slice of state they subscribe to changes — not every time any state changes.
- Actions live in the store: You define both state and state-modifying functions in one place, making your logic self-contained and easy to test.
- Works outside React: Because stores are plain JavaScript objects, you can read and update state from non-React code (utilities, event handlers, etc.).
- Middleware ecosystem: Persistence, DevTools, immer integration, and more are available as composable middleware.
Zustand's subscription model: components subscribe to slices of state independently
Installation and Setup
Getting Zustand into your project takes a single command and no configuration whatsoever. It works with React 16.8+ (hooks-based) and has TypeScript types built in.
Install Zustand
Add Zustand to your project using npm, yarn, or pnpm. No peer dependencies to worry about.
# npm
npm install zustand
# yarn
yarn add zustand
# pnpm
pnpm add zustand
Create Your First Store
Define a store file anywhere in your project — convention is src/store/ or src/stores/.
Use the Store in Components
Import the store hook and use it directly in any component — no Provider needed.
Creating Your First Store
A Zustand store is created with the create function. You pass it a callback that receives a set function — used to update state — and returns an object with your state and actions combined.
import { create } from 'zustand'
const useCounterStore = create((set) => ({
// State
count: 0,
// Actions
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}))
export default useCounterStore
Notice how actions live right next to state — no separate action creators, no switch statements in reducers. The set function can accept either an object (partial state merge) or a function that receives the current state and returns the new state.
Using the Store in Components
import useCounterStore from '../store/counterStore'
function Counter() {
// Subscribe to the entire store
const { count, increment, decrement, reset } = useCounterStore()
return (
<div>
<h2>Count: {count}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
)
}
// Another component that only reads the count
function CountDisplay() {
// Use a selector to subscribe only to 'count'
// This component only re-renders when count changes
const count = useCounterStore((state) => state.count)
return <p>Current count: {count}</p>
}
Always use a selector when subscribing to a store: useStore(state => state.count). Without a selector, the component re-renders whenever any part of the store changes. With a selector, it only re-renders when that specific value changes.
Advanced State Patterns
Async Actions and Data Fetching
Zustand has no special handling needed for async operations. Because actions are just functions, you can make them async and call set at any point — before, during, or after an async operation.
import { create } from 'zustand'
const useUserStore = create((set, get) => ({
users: [],
loading: false,
error: null,
selectedUser: null,
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('https://jsonplaceholder.typicode.com/users')
const users = await res.json()
set({ users, loading: false })
} catch (error) {
set({ error: error.message, loading: false })
}
},
selectUser: (id) => {
const user = get().users.find((u) => u.id === id)
set({ selectedUser: user })
},
clearSelected: () => set({ selectedUser: null }),
}))
export default useUserStore
Notice the second argument get — this gives you access to the current state from within any action, which is essential for operations that read before writing.
import { useEffect } from 'react'
import useUserStore from '../store/userStore'
function UserList() {
const users = useUserStore((s) => s.users)
const loading = useUserStore((s) => s.loading)
const error = useUserStore((s) => s.error)
const fetchUsers = useUserStore((s) => s.fetchUsers)
const selectUser = useUserStore((s) => s.selectUser)
useEffect(() => {
fetchUsers()
}, [fetchUsers])
if (loading) return <div>Loading users...</div>
if (error) return <div>Error: {error}</div>
return (
<ul>
{users.map((user) => (
<li key={user.id} onClick={() => selectUser(user.id)}>
{user.name} — {user.email}
</li>
))}
</ul>
)
}
Derived State with Selectors
Selectors can compute derived values on the fly. For expensive computations, combine Zustand with useMemo or use the dedicated useShallow hook to compare objects by value rather than reference.
import { useShallow } from 'zustand/react/shallow'
import useCartStore from '../store/cartStore'
function CartSummary() {
// useShallow prevents re-renders when object identity changes
// but values are the same
const { itemCount, totalPrice } = useCartStore(
useShallow((state) => ({
itemCount: state.items.reduce((sum, item) => sum + item.qty, 0),
totalPrice: state.items.reduce(
(sum, item) => sum + item.price * item.qty,
0
),
}))
)
return (
<div>
<p>{itemCount} items — ${totalPrice.toFixed(2)}</p>
</div>
)
}
Middleware: Persistence and DevTools
Zustand ships with a composable middleware system. The two most commonly used are persist (stores state in localStorage/sessionStorage) and devtools (integrates with Redux DevTools browser extension).
Persist Middleware
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useSettingsStore = create(
persist(
(set) => ({
theme: 'dark',
language: 'en',
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () =>
set((state) => ({ notifications: !state.notifications })),
}),
{
name: 'app-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ // only persist these fields
theme: state.theme,
language: state.language,
}),
}
)
)
export default useSettingsStore
The partialize option is important — without it, actions (functions) will also be serialized, causing errors. Always use partialize to specify only serializable state (not functions) for persistence.
DevTools Middleware
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useTodoStore = create(
devtools(
(set) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set(
(state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }] }),
false, // false = merge (not replace)
'todos/addTodo' // action name shown in DevTools
),
toggleTodo: (id) =>
set(
(state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
),
}),
false,
'todos/toggleTodo'
),
removeTodo: (id) =>
set(
(state) => ({ todos: state.todos.filter((t) => t.id !== id) }),
false,
'todos/removeTodo'
),
setFilter: (filter) => set({ filter }, false, 'todos/setFilter'),
}),
{ name: 'TodoStore' }
)
)
export default useTodoStore
Combining Middleware
You can combine persist, devtools, and immer together. The recommended order is devtools(persist(immer(...))):
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create(
devtools(
persist(
immer((set) => ({
items: [],
addItem: (item) =>
set((state) => {
// With immer, mutate directly!
state.items.push(item)
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id)
}),
})),
{ name: 'my-store' }
),
{ name: 'MyStore' }
)
)
TypeScript Integration
Zustand has excellent TypeScript support. The recommended pattern is to define your store's type interface and pass it as a generic to create:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CartItem {
id: number
name: string
price: number
qty: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'qty'>) => void
removeItem: (id: number) => void
updateQty: (id: number, qty: number) => void
clearCart: () => void
totalPrice: () => number
}
const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
),
}
}
return { items: [...state.items, { ...item, qty: 1 }] }
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQty: (id, qty) =>
set((state) => ({
items: qty === 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) => (i.id === id ? { ...i, qty } : i)),
})),
clearCart: () => set({ items: [] }),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.qty, 0),
}),
{
name: 'shopping-cart',
partialize: (state) => ({ items: state.items }),
}
)
)
export default useCartStore
export type { CartItem, CartState }
Note the extra ()() when using middleware with TypeScript: create<State>()(persist(...)). This is required due to TypeScript's inability to infer generic types with curried functions in a single call.
Real-World Example: Shopping Cart
Let's build a complete shopping cart using the TypeScript store from above. Here's how components consume the store cleanly:
function ProductCard({ product }) {
const addItem = useCartStore((s) => s.addItem)
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => addItem(product)}>
Add to Cart
</button>
</div>
)
}
// This component ONLY re-renders when item count changes
// Even if prices update, it stays quiet
function CartBadge() {
const itemCount = useCartStore(
(s) => s.items.reduce((sum, item) => sum + item.qty, 0)
)
if (itemCount === 0) return null
return (
<span className="badge">
{itemCount}
</span>
)
}
Zustand vs. Other State Libraries
Choosing a state management library is highly context-dependent. Here's an honest comparison to help you decide:
| Feature | Zustand | Redux Toolkit | Context API | Jotai |
|---|---|---|---|---|
| Bundle size | ~1KB | ~40KB | Built-in | ~3KB |
| Boilerplate | Minimal | Moderate | Low | Minimal |
| Provider needed | No | Yes | Yes | Optional |
| DevTools | Via middleware | Built-in | No | Via Jotai DevTools |
| Async support | Native (just async fn) | createAsyncThunk | Manual | Async atoms |
| Re-render optimization | Selectors | useSelector | Poor (whole tree) | Atom-level |
| Works outside React | Yes | Yes | No | No |
| Learning curve | Very low | Moderate | Low | Low |
| Best for | Most apps | Complex enterprise apps | Simple/low-frequency state | Atomic, fine-grained state |
The bottom line: use Zustand for almost any React project that needs global state. Reach for Redux Toolkit only if you need a highly structured team convention or have extremely complex state interactions with many developers. Avoid Context API for state that changes frequently — it doesn't optimize re-renders.
Zustand Patterns Quick Reference
| Pattern | Code |
|---|---|
| Read entire store | const state = useStore() |
| Read one field | const count = useStore(s => s.count) |
| Read multiple fields | const {a, b} = useStore(useShallow(s => ({a: s.a, b: s.b}))) |
| Update state (merge) | set({ count: 5 }) |
| Update state (functional) | set(s => ({ count: s.count + 1 })) |
| Replace state (not merge) | set({ count: 0 }, true) |
| Read state in action | const value = get().someField |
| Access store outside React | useStore.getState().someAction() |
| Subscribe outside React | useStore.subscribe(listener) |
| Reset store | set(initialState, true) |
When to Use Zustand
Zustand is an excellent default choice for the vast majority of React applications. Here's a practical decision guide:
Use Zustand When You Need
- Global state shared across unrelated components — user session, theme, cart, notifications
- State that lives outside the component tree — accessed in utility functions, WebSocket handlers, or service workers
- Async state with loading/error management — API calls, form submissions, file uploads
- Persisted state — settings, draft content, shopping cart (persist middleware handles localStorage)
- Performance-sensitive state — large lists, frequent updates where you need surgical re-renders
- Teams wanting fast onboarding — new developers understand Zustand in under an hour
"Zustand finally made me stop thinking about state management and start thinking about my product. It's the solution that gets out of your way."
— Common sentiment across the React community
Zustand represents a maturation of React state management thinking. It doesn't ask you to learn a new paradigm — it simply wraps JavaScript objects in a reactive shell. The library's philosophy trusts developers to structure their state sensibly rather than imposing rigid patterns, which is why it scales from tiny side projects to production applications at companies like Shopify, Vercel, and many others.
Start with a single store, see how far it takes you, and split into multiple stores only when complexity demands it. You'll be surprised how often a well-designed Zustand store handles what once required hundreds of lines of Redux boilerplate.