Zustand
Zustand is a minimal, fast state management library for React. Unlike Redux, there is no boilerplate — no actions, no reducers, no Provider wrapping your tree. You define a store in a single create() call, export a hook, and call that hook anywhere in your app.
Install it with:
npm install zustand
Creating Your First Store
create() accepts a function that receives set and returns an object containing both state values and the actions that update them. The returned value is a hook you use directly in components — no Provider needed.
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))Consuming the store in a component is straightforward. Pass a selector function to the hook to subscribe only to the slice of state you need — this prevents unnecessary re-renders:
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
)
}A Real-World Cart Store
Here is a complete shopping cart store. It manages a list of items and exposes add, remove, and a derived total — all in one place:
import { create } from 'zustand'
interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: number) => void
updateQuantity: (id: number, quantity: number) => void
clearCart: () => void
total: () => number
}
export const useCartStore = create<CartState>((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, quantity: i.quantity + 1 } : i
),
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
// get() reads current state without subscribing
total: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}))function CartSummary() {
const items = useCartStore((state) => state.items)
const total = useCartStore((state) => state.total)
const removeItem = useCartStore((state) => state.removeItem)
if (items.length === 0) return <p>Your cart is empty.</p>
return (
<div>
{items.map((item) => (
<div key={item.id}>
<span>{item.name} × {item.quantity}</span>
<span>${(item.price * item.quantity).toFixed(2)}</span>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<strong>Total: ${total().toFixed(2)}</strong>
</div>
)
}Async Actions
Actions in Zustand are plain functions — async ones work without any special middleware. Call set after your await completes:
interface UserState {
user: User | null
loading: boolean
error: string | null
fetchUser: (id: number) => Promise<void>
}
export const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to fetch user')
const user = await res.json()
set({ user, loading: false })
} catch (err) {
set({ error: (err as Error).message, loading: false })
}
},
}))Devtools Middleware
Wrap your store with devtools from zustand/middleware to get full Redux DevTools support — time-travel debugging, action logs, and state snapshots:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
reset: () => set({ count: 0 }, false, 'reset'),
}),
{ name: 'CounterStore' }
)
)Persist Middleware (localStorage)
The persist middleware serializes your store to localStorage automatically. On page load, it rehydrates from the saved value:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => ({ /* ... */ })),
removeItem: (id) => set((state) => ({ /* ... */ })),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{
name: 'cart-storage', // localStorage key
// Optionally persist only a subset of state:
partialize: (state) => ({ items: state.items }),
}
)
)Slices Pattern for Large Stores
When a store grows large, split it into slices. Each slice is a function that receives set and get and returns its portion of state. Combine them with create:
// slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
items: [] as CartItem[],
addItem: (item) => set((state) => ({ /* ... */ })),
removeItem: (id) => set((state) => ({ /* ... */ })),
})
// slices/userSlice.ts
export const createUserSlice = (set) => ({
user: null as User | null,
setUser: (user: User) => set({ user }),
logout: () => set({ user: null }),
})
// store.ts
import { create } from 'zustand'
import { createCartSlice } from './slices/cartSlice'
import { createUserSlice } from './slices/userSlice'
export const useStore = create((set, get) => ({
...createCartSlice(set, get),
...createUserSlice(set),
}))Zustand vs Redux — Why Developers Switch
Concern | Redux Toolkit | Zustand |
|---|---|---|
Setup | store + slices + Provider | One create() call |
Boilerplate | createSlice, createAsyncThunk | Plain functions |
Provider | Required (<Provider store={store}>) | Not required |
Bundle size | ~47 kB | ~3 kB |
DevTools | Built-in excellent support | Via devtools middleware |
Learning curve | Moderate (flux mental model) | Low |
Best for | Large teams, strict structure | Most apps |
No
Providermeans you can use the store in non-React contexts (event handlers, utilities, server code).Selecting primitives from the store keeps re-renders minimal without
useMemoorshallowEqual.Combine with
devtools+persistfor production-grade stores with zero extra packages.The slices pattern scales to large apps while keeping files focused.