A minimal, unopinionated state management library for React with a hooks-based API.
- Store: A single source of truth holding your application state
- Hooks-based: Access state using hooks, no providers needed
- Immutable updates: State changes require creating new objects/arrays
- Selectors: Functions to extract specific slices of state
- Middleware: Enhance stores with persistence, devtools, immer, etc.
- No boilerplate: Minimal setup compared to Redux or Context API
┌──────────────────────────────┐
│ ZUSTAND STORE │
│──────────────────────────────│
│ state │
│ actions (set / get) │
│ listeners (subscriptions) │
└─────────────┬────────────────┘
│
set() │ useStore(selector)
(update state) │ (subscribe to slice only)
│
▼
┌──────────────────────────────┐
│ REACT COMPONENTS │
│──────────────────────────────│
│ useStore(selector) │
│ → subscribes to slice only │
│ → re-renders selectively │
└──────────────────────────────┘
1. create((set, get) => ({ state + actions }))
↓
2. Component subscribes
useStore(state => state.count)
↓
3. Zustand registers listener
listeners.add(component)
↓
4. Action triggers update
set(state => newState)
↓
5. Store updates state
↓
6. Notify only affected subscribers
↓
7. React re-renders ONLY those components
npm install zustand
# or
yarn add zustandimport { create } from 'zustand'
const useStore = create((set) => ({
// State
count: 0,
// Actions
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}))Explanation:
create()returns a hookset()merges partial state (shallow merge)- Actions are functions that call
set()to update state - Arrow functions in
set()receive current state
function Counter() {
const count = useStore((state) => state.count)
const increment = useStore((state) => state.increment)
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
</div>
)
}Explanation:
- Call the hook with a selector function
- Component re-renders only when selected state changes
- Multiple selectors = multiple subscriptions
// Partial update (shallow merge)
set({ count: 1 })
// Function form (access current state)
set((state) => ({ count: state.count + 1 }))
// Replace entire state (use carefully)
set({ count: 0 }, true) // second param = replaceconst useStore = create((set, get) => ({
count: 0,
increment: () => {
const current = get().count
set({ count: current + 1 })
}
}))Explanation: get() retrieves current state synchronously within actions
const count = useStore((state) => state.count)// ❌ Causes re-renders on any state change
const { count, user } = useStore((state) => state)
// ✅ Only re-renders when count OR user changes
const count = useStore((state) => state.count)
const user = useStore((state) => state.user)
// ✅ Use shallow equality for objects
import { shallow } from 'zustand/shallow'
const { count, user } = useStore(
(state) => ({ count: state.count, user: state.user }),
shallow
)Explanation:
- Default equality:
Object.is()(strict equality) - Destructuring entire state causes unnecessary re-renders
shallowcompares object properties for equality
const useStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
}))
}))
// Computed value (memoized automatically)
const itemCount = useStore((state) => state.items.length)const useStore = create((set) => ({
users: [],
loading: false,
fetchUsers: async () => {
set({ loading: true })
const response = await fetch('/api/users')
const users = await response.json()
set({ users, loading: false })
}
}))Explanation:
- Actions can be async functions
- Call
set()multiple times to update loading states - No special async middleware needed
const useStore = create((set) => ({
user: { name: 'John', age: 30 },
updateName: (name) => set((state) => ({
user: { ...state.user, name }
}))
}))import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: { name: 'John', age: 30 },
updateName: (name) => set((state) => {
state.user.name = name // Direct mutation with Immer
})
}))
)Explanation: Immer allows "mutative" syntax while maintaining immutability
import { persist } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
{
name: 'counter-storage', // localStorage key
// storage: sessionStorage, // optional
}
)
)import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
{ name: 'CounterStore' }
)
)const useStore = create(
devtools(
persist(
immer((set) => ({
// store definition
})),
{ name: 'storage-key' }
),
{ name: 'StoreName' }
)
)Explanation: Middleware wraps stores, order matters (outer middleware wraps inner)
interface StoreState {
count: number
increment: () => void
decrement: () => void
}
const useStore = create<StoreState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 }))
}))import { persist, devtools } from 'zustand/middleware'
interface StoreState {
count: number
increment: () => void
}
const useStore = create<StoreState>()(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
{ name: 'counter' }
)
)
)Explanation: Notice create<Type>()() - double invocation needed with middleware
const createUserSlice = (set) => ({
user: null,
setUser: (user) => set({ user })
})
const createSettingsSlice = (set) => ({
theme: 'light',
setTheme: (theme) => set({ theme })
})
const useStore = create((set) => ({
...createUserSlice(set),
...createSettingsSlice(set)
}))Explanation: Split large stores into logical slices for better organization
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}))
// Get current state
const count = useStore.getState().count
// Subscribe to changes
const unsubscribe = useStore.subscribe(
(state) => console.log('State changed:', state)
)
// Update state
useStore.getState().increment()
// Cleanup
unsubscribe()Explanation: Useful for utilities, API interceptors, or non-React code
- Keep stores flat: Avoid deeply nested state when possible
- Single store vs multiple stores: Use single store for related data, multiple stores for independent domains
- Colocate actions with state: Define actions inside the store creation
- Use selectors wisely: Select only what you need to minimize re-renders
- Avoid mixing concerns: Don't put UI state and server state in the same store
- Name actions clearly: Use verbs (increment, fetchUsers, setTheme)
- Use middleware for cross-cutting concerns: Persistence, logging, devtools
- TypeScript: Always type your stores for better DX
| Mistake | Why It's Wrong | Solution |
|---|---|---|
const state = useStore() |
Subscribes to entire store, re-renders on any change | Use selectors: useStore(s => s.count) |
| Mutating state directly | Breaks immutability, no re-renders | Use set() with new objects/arrays |
set({ items: state.items.push(x) }) |
push() returns length, not array |
set(s => ({ items: [...s.items, x] })) |
Using get() in components |
Not reactive, won't trigger re-renders | Use hook with selector instead |
Not using shallow for object selectors |
Causes re-renders even when values unchanged | Import and use shallow equality |
| Forgetting async handling | UI doesn't reflect loading states | Set loading flags before/after async calls |
// ❌ New object every render
const data = useStore((state) => ({
count: state.count,
name: state.name
}))
// ✅ Use shallow equality
import { shallow } from 'zustand/shallow'
const data = useStore(
(state) => ({ count: state.count, name: state.name }),
shallow
)
// ✅ Or select primitives separately
const count = useStore((state) => state.count)
const name = useStore((state) => state.name)const useStore = create(
subscribeWithSelector((set) => ({
count: 0,
user: null
}))
)
// Only runs when count changes
useEffect(() => {
const unsubscribe = useStore.subscribe(
(state) => state.count,
(count) => console.log('Count:', count)
)
return unsubscribe
}, [])Explanation: subscribeWithSelector middleware enables granular subscriptions
// 1. Create store
import { create } from 'zustand'
const useStore = create((set, get) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 }))
}))
// 2. Use in component
const count = useStore((state) => state.count)
const increment = useStore((state) => state.increment)
// 3. Multiple values with shallow
import { shallow } from 'zustand/shallow'
const { count, user } = useStore(
(s) => ({ count: s.count, user: s.user }),
shallow
)
// 4. Persist
import { persist } from 'zustand/middleware'
const useStore = create(persist(
(set) => ({ /* ... */ }),
{ name: 'storage-key' }
))
// 5. Outside React
useStore.getState().increment()
const unsub = useStore.subscribe(console.log)
Zustand: Complete Breakdown for React/Next.js
1. High-Level Overview
┌─────────────────────────────────────────────────────────────┐ │ ZUSTAND ARCHITECTURE │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ STORE CREATION │ │ │ │ create() → Returns hook → Components consume │ │ │ └──────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ STATE CONTAINER (Vanilla JS) │ │ │ │ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │ │ │ │ │ State │ │ Actions │ │ Selectors │ │ │ │ │ │ (Immutable)│ │ (Mutators) │ │ (Derived) │ │ │ │ │ └────────────┘ └────────────┘ └──────────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ SUBSCRIPTION SYSTEM │ │ │ │ Components subscribe → State changes → Re-render │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ KEY FEATURES │ │ │ ├──────────────────────────────────────────────────────┤ │ │ │ • No Context Provider needed │ │ │ │ • No boilerplate (actions, reducers, types) │ │ │ │ • Minimal re-renders (granular subscriptions) │ │ │ │ • Works outside React (vanilla store) │ │ │ │ • Async actions built-in │ │ │ │ • Middleware support (persist, devtools, immer) │ │ │ │ • TypeScript-first │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘What is Zustand?
2. Core Concepts
2.1 Basic Store Creation
How it works:
2.2 Using the Store in Components
Subscription Patterns:
2.3 The
setFunction Deep Diveset()Signatures:2.4 The
getFunction3. Real-World Store Examples
3.1 Authentication Store
Usage:
3.2 Shopping Cart Store
Usage:
3.3 Todo Store with Advanced Features
Usage:
4. Middleware
4.1 Persist Middleware
4.2 DevTools Middleware
4.3 Immer Middleware (Mutable Updates)
4.4 Custom Middleware
5. Advanced Patterns
5.1 Slices Pattern (Modular Stores)
5.2 Async Actions & Loading States
5.3 Computed Values (Selectors)
5.4 Outside React Usage (Vanilla Store)
6. Next.js Integration
6.1 Server-Side Initialization
Usage in Server Component:
6.2 Preventing Hydration Mismatch
7. Testing
8. Performance Optimization
8.1 Selective Re-rendering
8.2 Memoized Selectors
9. Migration Guide
From Redux to Zustand
From Context to Zustand
10. Best Practices Summary
✅ DO:
❌ DON'T:
Zustand provides a simple, powerful, and flexible state management solution that eliminates Redux boilerplate while maintaining all the benefits of centralized state. Its small bundle size, TypeScript support, and middleware ecosystem make it ideal for modern React applications.