Zustand: Lightweight State Management for React
Complete guide to Zustand — store creation, subscriptions, middleware, and comparison with Redux and Context API
Introduction
Zustand is a small, fast, and scalable state management solution for React. Unlike Redux, it requires no providers, no action types, and no reducers. It's built on hooks and works outside React components too — making it one of the most flexible state libraries available.
Why Zustand?
The Problem with Other Solutions
// ❌ Redux - lots of boilerplate
// Actions, reducers, store, Provider, connect/mapStateToProps...
// ❌ React Context - re-renders all consumers
// Every context value change re-renders every consumer
// ✅ Zustand - minimal, performant, flexible
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}));
// Use in any component
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
Core Concepts
Creating a Store
import { create } from 'zustand';
// Basic store
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
}));
// Store with get for accessing state without subscription
const useStore = create((set, get) => ({
count: 0,
increment: () => set({ count: get().count + 1 }),
// Async action
fetchCount: async () => {
const response = await fetch('/api/count');
const data = await response.json();
set({ count: data.count });
}
}));
Selecting State (Performance)
The key to Zustand performance is selective subscriptions:
// ❌ Re-renders on ANY state change
const state = useStore();
// ✅ Only re-renders when count changes
const count = useStore((state) => state.count);
// ✅ Select multiple values with shallow comparison
import { shallow } from 'zustand/shallow';
const { count, increment } = useStore(
(state) => ({ count: state.count, increment: state.increment }),
shallow
);
// ✅ Use a selector function
const bears = useStore((state) => state.bears);
const fish = useStore((state) => state.fish);
Updating State
const useStore = create((set) => ({
user: { name: 'Alice', age: 30 },
items: [1, 2, 3],
// Replace entire value
setUser: (user) => set({ user }),
// Merge (shallow)
updateUser: (updates) => set((state) => ({
user: { ...state.user, ...updates }
})),
// Nested update with immer middleware
incrementAge: () => set((state) => ({
user: { ...state.user, age: state.user.age + 1 }
})),
// Array operations
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
// Replace state entirely
reset: () => set({ user: { name: '', age: 0 }, items: [] })
}));
Middleware
Immer Middleware
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useStore = create(
immer((set) => ({
user: {
name: 'Alice',
address: { city: 'Paris', zip: '75001' }
},
// Mutate state directly with Immer
updateCity: (city) => set((state) => {
state.user.address.city = city;
}),
updateZip: (zip) => set((state) => {
state.user.address.zip = zip;
})
}))
);
Persist Middleware
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
theme: 'dark',
setTheme: (theme) => set({ theme })
}),
{
name: 'app-settings', // localStorage key
partialize: (state) => ({ // Only persist theme
theme: state.theme
})
}
)
);
DevTools Middleware
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set(
(state) => ({ count: state.count + 1 }),
false,
'increment' // Action name in DevTools
)
}),
{ name: 'MyStore' }
)
);
Combining Middleware
import { create } from 'zustand';
import { devtools, persist, immer } from 'zustand/middleware';
const useStore = create(
devtools(
persist(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
state.todos.push({ text, done: false });
})
})),
{ name: 'todos-storage' }
),
{ name: 'TodosStore' }
)
);
Subscription Management
Automatic vs Manual
// Zustand subscriptions are semi-automatic:
// - Components auto-subscribe via hooks
// - API available for manual unsubscription
// Manual subscription (outside React)
const unsubscribe = useStore.subscribe(
(state) => state.count,
(count) => {
console.log('Count changed:', count);
}
);
// Unsubscribe when done
unsubscribe();
// Subscribe to entire state
const unsubscribe = useStore.subscribe(
(state) => state,
(state) => {
console.log('State changed:', state);
},
{ equalityFn: shallow }
);
Using Store Outside React
// Zustand stores work outside React components
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// Get state without subscribing
const count = useStore.getState().count;
// Set state directly
useStore.setState({ count: 10 });
// Subscribe to changes
const unsubscribe = useStore.subscribe(console.log);
// In tests, WebSocket handlers, event listeners, etc.
websocket.on('message', (data) => {
useStore.getState().handleMessage(data);
});
TypeScript Patterns
import { create } from 'zustand';
// Define store interface
interface BearStore {
bears: number;
increase: () => void;
decrease: () => void;
reset: () => void;
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
decrease: () => set((state) => ({ bears: state.bears - 1 })),
reset: () => set({ bears: 0 })
}));
// With middleware types
import { persist } from 'zustand/middleware';
interface SettingsStore {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme })
}),
{ name: 'settings' }
)
);
Common Patterns
Slice Pattern (Multiple Stores)
// Split state into focused stores
const useBearStore = create((set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 }))
}));
const useFishStore = create((set) => ({
fish: 0,
addFish: () => set((state) => ({ fish: state.fish + 1 }))
}));
// Compose in components
function AnimalCounter() {
const bears = useBearStore((s) => s.bears);
const fish = useFishStore((s) => s.fish);
return <div>Bears: {bears}, Fish: {fish}</div>;
}
Computed Values
const useStore = create((set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
// Computed: derived from state
getItemCount: () => get().items.length,
getTotalPrice: () => get().items.reduce((sum, item) => sum + item.price, 0)
}));
// Use in component
function Cart() {
const items = useStore((s) => s.items);
const itemCount = useStore((s) => s.getItemCount());
const totalPrice = useStore((s) => s.getTotalPrice());
return (
<div>
<span>{itemCount} items</span>
<span>Total: ${totalPrice}</span>
</div>
);
}
Async Actions
const useStore = create((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
const users = await response.json();
set({ users, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
// Optimistic update
updateUser: async (id, updates) => {
const previousUsers = get().users;
// Optimistic update
set((state) => ({
users: state.users.map(u => u.id === id ? { ...u, ...updates } : u)
}));
try {
await fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
} catch {
// Rollback on failure
set({ users: previousUsers });
}
}
}));
Zustand vs Redux vs Context
| Feature | Zustand | Redux Toolkit | React Context |
|---|---|---|---|
| Bundle size | ~1 KB | ~11 KB | 0 KB (built-in) |
| Boilerplate | Minimal | Low (with RTK) | Minimal |
| Provider needed | No | Yes | Yes |
| Outside React | Yes | Yes | No |
| DevTools | Yes | Yes | No |
| Middleware | Yes | Yes | No |
| Performance | Selective re-renders | Selective (with selectors) | Re-renders all consumers |
| Learning curve | Low | Medium | Low |
Best Practices
Do's
// ✅ Use selective selectors for performance
const count = useStore((s) => s.count);
// ✅ Split into multiple small stores
const useUserStore = create(/* ... */);
const useCartStore = create(/* ... */);
// ✅ Use middleware for cross-cutting concerns
// persist, devtools, immer
// ✅ Access state outside React when needed
useStore.getState().someValue;
// ✅ Use shallow comparison for multiple values
import { shallow } from 'zustand/shallow';
const { a, b } = useStore(selector, shallow);
Don'ts
// ❌ Don't select the entire store
const state = useStore(); // Re-renders on every change
// ❌ Don't create stores inside components
function Component() {
const store = create(/* ... */); // New store every render!
}
// ❌ Don't mutate state outside set()
const state = useStore.getState();
state.count = 5; // Won't trigger re-renders
// ❌ Don't forget to unsubscribe from manual subscriptions
const unsub = useStore.subscribe(/* ... */);
// Always call unsub() when done
Summary Table
| API | Purpose | Example |
|---|---|---|
create(fn) | Create store | create((set) => ({...})) |
set(state) | Update state | set({ count: 1 }) |
set(fn) | Functional update | set((s) => ({ count: s.count + 1 })) |
get() | Get current state | get().count |
useStore(sel) | Subscribe in component | useStore((s) => s.count) |
useStore.getState() | Get state outside React | useStore.getState() |
useStore.setState() | Set state outside React | useStore.setState({...}) |
useStore.subscribe() | Manual subscription | useStore.subscribe(fn) |
Conclusion
Zustand is the lightweight state management solution that gets out of your way:
- No providers — use stores directly in any component
- No boilerplate — just
create()andset() - Works everywhere — inside and outside React
- Tiny bundle — ~1 KB gzipped
- TypeScript-first — excellent type inference
- Flexible — middleware for persistence, devtools, immer
Choose Zustand when you want simple, performant state management without the ceremony of Redux or the re-render issues of Context.
#zustand #react #state-management #javascript #typescript #web-development