Zustand for Lightweight State Management in React
Learn Zustand, the minimal React state management library with no boilerplate, built-in selectors, and a hook-based API.
What you'll learn
- ✓Creating a Zustand store
- ✓Selectors and preventing unnecessary re-renders
- ✓Async actions and middleware
- ✓Persisting state to localStorage
- ✓Comparing Zustand to Redux and Context
Prerequisites
- •Basic React knowledge
- •Familiarity with hooks
Zustand is a small, fast state management library for React. It gives you a global store with a hook-based API, built-in selectors for performance, and zero boilerplate. There are no providers, no reducers, no action types. You create a store, use it in your components, and that is it.
Creating a Store
A Zustand store is created with the create function. You pass it a callback that receives a set function and returns an object with your state and actions.
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 }),
}));
The create function returns a hook. Use it in any component:
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
const decrement = useStore((state) => state.decrement);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
No <Provider> wrapping. No context. The store lives outside the React tree, and components subscribe to the slices they need.
Selectors: The Key to Performance
The function you pass to useStore is a selector. Zustand only re-renders the component when the selected value changes (using Object.is comparison). This is the critical performance advantage over Context.
// Only re-renders when `count` changes
function CountDisplay() {
const count = useStore((state) => state.count);
return <p>{count}</p>;
}
// Only re-renders when `user` changes
function UserGreeting() {
const user = useStore((state) => state.user);
return <p>Hello, {user?.name}</p>;
}
If you select multiple values, return them in a shallow-compared object using useShallow:
import { useShallow } from 'zustand/react/shallow';
function UserInfo() {
const { name, email } = useStore(
useShallow((state) => ({ name: state.user.name, email: state.user.email }))
);
return (
<div>
<p>{name}</p>
<p>{email}</p>
</div>
);
}
Without useShallow, the component would re-render on every store update because the selector creates a new object each time.
Async Actions
Actions can be async. Use set after the async operation completes.
const useStore = create((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/users');
const users = await res.json();
set({ users, loading: false });
} catch (err) {
set({ error: err.message, loading: false });
}
},
}));
function UserList() {
const users = useStore((state) => state.users);
const loading = useStore((state) => state.loading);
const fetchUsers = useStore((state) => state.fetchUsers);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (loading) return <p>Loading...</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
Accessing State Outside React
Zustand stores expose getState and setState methods for use outside React components. This is useful in utility functions, API interceptors, or tests.
// Read state
const currentCount = useStore.getState().count;
// Update state
useStore.setState({ count: 0 });
// Subscribe to changes
const unsub = useStore.subscribe(
(state) => console.log('Count is now:', state.count)
);
Middleware: Persist
Zustand has a plugin system based on middleware. The persist middleware saves state to localStorage and restores it on reload.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'settings-storage', // localStorage key
partialize: (state) => ({
theme: state.theme,
language: state.language,
}),
}
)
);
The partialize option controls which parts of the state are persisted. Actions and computed values are excluded automatically.
Middleware: Devtools
Connect your store to the Redux DevTools browser extension for time-travel debugging.
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: 'CounterStore' }
)
);
Combining Middleware
Middleware composes by nesting. Combine devtools and persist:
const useStore = create(
devtools(
persist(
(set) => ({
todos: [],
addTodo: (text) =>
set(
(state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }] }),
false,
'addTodo'
),
}),
{ name: 'todo-storage' }
),
{ name: 'TodoStore' }
)
);
Slicing Stores
For large applications, split your store into slices and combine them.
const createUserSlice = (set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
});
const createCartSlice = (set) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
});
const useStore = create((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}));
Each slice is a plain function that receives set and returns a piece of the state. The combined store merges them.
Zustand vs Redux vs Context
| Feature | Zustand | Redux Toolkit | Context |
|---|---|---|---|
| Boilerplate | Minimal | Moderate | Low |
| Bundle size | ~1 KB | ~11 KB | 0 (built-in) |
| Provider required | No | Yes | Yes |
| Built-in selectors | Yes | Via useSelector | No |
| Devtools | Middleware | Built-in | No |
| Async actions | Native | Via thunks/sagas | Manual |
| Learning curve | Low | Medium | Low |
Use Zustand when you need global state without the ceremony of Redux. Use Context for truly static, rarely-changing values like themes. Use Redux Toolkit when you need the ecosystem: RTK Query, structured patterns for large teams, and extensive middleware.
Zustand hits the sweet spot for most React applications. It is small enough to learn in an afternoon and powerful enough to manage complex state at scale.
Related articles
- React React State Management: Context vs Redux vs Zustand
Compare React state management solutions: Context API, Redux Toolkit, and Zustand. Learn when to use each with practical examples and trade-offs.
- React React Context vs Redux: When to Use Which
A practical comparison of React Context and Redux: rendering model, performance, devtools, and concrete heuristics for picking the right tool.
- React React State Management: Zustand vs Redux vs Context Compared
Compare Zustand, Redux Toolkit, and React Context for state management. Learn when each shines, their trade-offs, and how to pick the right tool.
- React useReducer in React and Why It Beats useState Sometimes
Understand React's useReducer hook, its signature, action shape, when to migrate from useState, and how to build a small state machine pattern for predictable UI logic.