Zustand Guide: Lightweight State Management for React 19
State management is one of the most critical architectural decisions in React applications. Redux provides power at the cost of boilerplate. Context API works for simple cases but becomes unwieldy at scale. Zustand offers a compelling middle ground: simple, fast, and with minimal boilerplate.
In this guide, you'll master Zustand from fundamentals to production patterns. You'll understand why thousands of developers are adopting it, how to structure stores for large applications, and when to use it versus alternatives.
Table of Contents
- Why Zustand?
- Getting Started
- Creating Your First Store
- Using Stores in Components
- State Updates & Immutability
- Async Actions
- Store Slicing & Splitting
- Middleware & Plugins
- DevTools Integration
- Best Practices
- Zustand vs Alternatives
- Real-World Examples
- FAQ
Why Zustand? {#why-zustand}
The Problem with Alternatives
Redux is powerful but verbose:
- Requires actions, reducers, action creators, selectors
- High learning curve and setup overhead
- Many boilerplate files for simple features
Context API is too simple:
- Performance issues with frequent updates
- Causes unnecessary re-renders
- Scales poorly with complex state
Zustand is the Goldilocks solution:
- Dead simple API (one function call per store)
- Tiny bundle size (~2KB minified)
- Zero boilerplate
- Excellent TypeScript support
- Built-in DevTools support
Zustand's Philosophy
// Zustand: You write what you see
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// That's it. No actions, no reducers, no middleware setup.
// You get a hook. Use it anywhere.
Getting Started {#getting-started}
Installation
npm install zustand
# or
yarn add zustand
# or
pnpm add zustand
Peer Dependencies
Zustand requires React 16.8+ (for hooks). No other dependencies.
{
"peerDependencies": {
"react": ">=16.8"
}
}
What You Get
- A single
createfunction - TypeScript support out of the box
- DevTools middleware included
- No provider setup needed
Creating Your First Store {#first-store}
Basic Store Structure
import { create } from 'zustand';
// ✅ Simple counter store
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Store Anatomy
A Zustand store consists of:
- State values — The data you want to share
- Actions — Functions that update state via
set() - The
setfunction — Updates state (receives previous state)
interface StoreType {
// State
value: string;
// Actions
updateValue: (newValue: string) => void;
clear: () => void;
}
export const useStore = create<StoreType>((set) => ({
// Initial state
value: '',
// Actions that modify state
updateValue: (newValue) => set({ value: newValue }),
clear: () => set({ value: '' }),
}));
JavaScript Version
import { create } from 'zustand';
export const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Using Stores in Components {#using-stores}
Basic Hook Usage
import { useCounterStore } from './store';
export function Counter() {
// Hook automatically subscribes to store changes
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
Destructuring Multiple Values
export function Counter() {
// ✅ Subscribe to multiple values at once
const { count, increment, decrement } = useCounterStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
Selective Subscription (Performance Optimization)
export function CounterDisplay() {
// ✅ Only subscribe to count, not actions
// Component won't re-render if increment/decrement changes
const count = useCounterStore((state) => state.count);
return <div>Count: {count}</div>;
}
export function CounterControls() {
// ✅ Only subscribe to actions, not count
// Component won't re-render when count changes
const { increment, decrement } = useCounterStore();
return (
<>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</>
);
}
Using Multiple Stores
interface UserStore {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
interface ThemeStore {
isDark: boolean;
toggle: () => void;
}
const useUserStore = create<UserStore>((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
const useThemeStore = create<ThemeStore>((set) => ({
isDark: false,
toggle: () => set((state) => ({ isDark: !state.isDark })),
}));
export function App() {
const { user } = useUserStore();
const { isDark } = useThemeStore();
return (
<div className={isDark ? 'dark' : 'light'}>
<p>Welcome, {user?.name}</p>
</div>
);
}
State Updates & Immutability {#state-updates}
Immutable Updates
Zustand uses immer internally (optional but recommended). Always return new objects:
interface TodoStore {
todos: Todo[];
addTodo: (todo: Todo) => void;
updateTodo: (id: string, updates: Partial<Todo>) => void;
removeTodo: (id: string) => void;
}
interface Todo {
id: string;
text: string;
completed: boolean;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
// ✅ CORRECT: Return new array
addTodo: (todo) => set((state) => ({
todos: [...state.todos, todo],
})),
// ✅ CORRECT: Map to new array
updateTodo: (id, updates) => set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, ...updates } : todo
),
})),
// ✅ CORRECT: Filter to new array
removeTodo: (id) => set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
}));
Using Immer Middleware for Complex Updates
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface StateType {
nested: {
deep: {
value: string;
};
};
updateValue: (newValue: string) => void;
}
// ✅ With immer, you can mutate state directly
export const useStore = create<StateType>()(
immer((set) => ({
nested: {
deep: {
value: 'initial',
},
},
// ✅ Can mutate draft state directly
updateValue: (newValue) => set((state) => {
state.nested.deep.value = newValue;
// No need to return anything or track immutability
}),
}))
);
Async Actions {#async-actions}
Basic Async Pattern
interface ApiStore {
data: Data[] | null;
loading: boolean;
error: Error | null;
fetchData: () => Promise<void>;
}
interface Data {
id: string;
name: string;
}
export const useApiStore = create<ApiStore>((set) => ({
data: null,
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/data');
const data = await response.json();
set({ data, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error : new Error('Unknown error'),
loading: false,
});
}
},
}));
Using Async with Component
import { useEffect } from 'react';
import { useApiStore } from './store';
export function DataList() {
const { data, loading, error, fetchData } = useApiStore();
useEffect(() => {
fetchData();
}, [fetchData]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
Complex Async with Optimistic Updates
interface CartStore {
items: CartItem[];
isUpdating: boolean;
addItem: (item: CartItem) => Promise<void>;
}
interface CartItem {
id: string;
name: string;
quantity: number;
}
export const useCartStore = create<CartStore>((set) => ({
items: [],
isUpdating: false,
addItem: async (item) => {
// ✅ Optimistic update
set((state) => ({
items: [...state.items, item],
isUpdating: true,
}));
try {
// Send to server
const response = await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item),
});
if (!response.ok) throw new Error('Failed to add item');
const savedItem = await response.json();
// Update with server response if different
set((state) => ({
items: state.items.map((i) =>
i.id === item.id ? savedItem : i
),
isUpdating: false,
}));
} catch (error) {
// Rollback on error
set((state) => ({
items: state.items.filter((i) => i.id !== item.id),
isUpdating: false,
}));
throw error;
}
},
}));
Store Slicing & Splitting {#store-slicing}
One Store vs Multiple Stores
For most apps, one store per feature is better than one big store:
// ✅ GOOD: Feature-based stores
const useAuthStore = create(...); // Authentication
const useTodoStore = create(...); // Todos
const useUIStore = create(...); // UI state
// ❌ AVOID: One monolithic store
const useAppStore = create((set) => ({
user: null,
todos: [],
theme: 'light',
notifications: [],
// ... all state mixed together
}));
Composing Stores
Combine stores when they share logic:
// User store
interface User {
id: string;
name: string;
}
interface UserStore {
user: User | null;
setUser: (user: User) => void;
}
const useUserStore = create<UserStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
// Settings store that depends on user
interface SettingsStore {
userPreferences: Preferences | null;
updatePreferences: (prefs: Preferences) => void;
}
interface Preferences {
theme: string;
language: string;
}
const useSettingsStore = create<SettingsStore>((set) => ({
userPreferences: null,
updatePreferences: (prefs) => {
// Can subscribe to other stores
const user = useUserStore.getState().user;
if (!user) {
console.error('Must be logged in to update preferences');
return;
}
set({ userPreferences: prefs });
},
}));
Store Factory Pattern for Reusable Stores
// Create a store factory for paginated lists
interface PaginatedStore<T> {
items: T[];
page: number;
pageSize: number;
total: number;
loading: boolean;
nextPage: () => void;
prevPage: () => void;
}
function createPaginatedStore<T>(
fetchFn: (page: number, pageSize: number) => Promise<{
items: T[];
total: number;
}>
) {
return create<PaginatedStore<T>>((set) => ({
items: [],
page: 1,
pageSize: 10,
total: 0,
loading: false,
nextPage: async () => {
set((state) => ({ page: state.page + 1, loading: true }));
const { items, total } = await fetchFn(
usePageStore.getState().page,
usePageStore.getState().pageSize
);
set({ items, total, loading: false });
},
prevPage: async () => {
set((state) => ({
page: Math.max(state.page - 1, 1),
loading: true,
}));
const { items, total } = await fetchFn(
usePageStore.getState().page,
usePageStore.getState().pageSize
);
set({ items, total, loading: false });
},
}));
}
// Usage
const useProductsStore = createPaginatedStore(
async (page, pageSize) => {
const response = await fetch(
`/api/products?page=${page}&pageSize=${pageSize}`
);
return response.json();
}
);
Middleware & Plugins {#middleware}
Persist Middleware (LocalStorage)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface PreferencesStore {
theme: 'light' | 'dark';
language: string;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
}
// ✅ Automatically persist to localStorage
export const usePreferencesStore = create<PreferencesStore>()(
persist(
(set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'preferences-storage', // key in localStorage
partialize: (state) => ({
theme: state.theme,
language: state.language,
// Don't persist methods
}),
}
)
);
Subscribe to Store Changes
// ✅ Listen to all changes
const unsubscribe = useCounterStore.subscribe(
(state) => state.count,
(count) => {
console.log('Count changed to:', count);
}
);
// Unsubscribe later
unsubscribe();
// ✅ React to entire state changes
useCounterStore.subscribe(
(state) => state,
(newState) => {
console.log('Store state updated:', newState);
}
);
Logging Middleware
import { create } from 'zustand';
interface LogStore {
count: number;
increment: () => void;
}
// Custom logging middleware
const logMiddleware = (f: any) => (set: any, get: any, api: any) =>
f(
(...args: any) => {
console.log('Setting state:', args);
set(...args);
console.log('New state:', get());
},
get,
api
);
export const useLogStore = create<LogStore>(
logMiddleware((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);
DevTools Integration {#devtools}
Enable DevTools for Debugging
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface CounterStore {
count: number;
increment: () => void;
}
export const useCounterStore = create<CounterStore>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'Counter Store', // Display name in DevTools
}
)
);
Install Redux DevTools Extension
- Install browser extension (Chrome, Firefox)
- Use
devtoolsmiddleware as shown above - Open Redux DevTools to see:
- State snapshots
- Action history
- Time-travel debugging
- State diffs
Best Practices {#best-practices}
1. Use Selective Subscriptions
// ✅ GOOD: Only subscribe to what you need
const count = useCounterStore((state) => state.count);
// ❌ AVOID: Destructuring entire store
const store = useCounterStore(); // Subscribes to everything
2. Keep Stores Focused
// ✅ GOOD: Single responsibility
const useAuthStore = create(...);
const useUIStore = create(...);
const useDataStore = create(...);
// ❌ AVOID: Kitchen sink store
const useAppStore = create((set) => ({
// auth stuff
// ui stuff
// data stuff
// all mixed together
}));
3. Extract Derived State
// ❌ WRONG: Store derived values
interface CartStoreWrong {
items: CartItem[];
total: number; // Derived from items
}
// ✅ CORRECT: Compute derived values
interface CartStoreRight {
items: CartItem[];
}
export function CartTotal() {
const items = useCartStore((state) => state.items);
// Compute total here or in a custom hook
const total = items.reduce((sum, item) => sum + item.price, 0);
return <div>Total: ${total}</div>;
}
// Or use a custom hook
function useCartTotal() {
const items = useCartStore((state) => state.items);
return items.reduce((sum, item) => sum + item.price, 0);
}
4. Use get() for Accessing State in Actions
interface StoreType {
user: User | null;
count: number;
complexAction: () => void;
}
export const useStore = create<StoreType>((set, get) => ({
user: null,
count: 0,
// ✅ Use get() to access current state
complexAction: () => {
const { user, count } = get();
if (user) {
set({ count: count + 1 });
}
},
}));
5. Type Safety Best Practices
// ✅ GOOD: Explicit types
interface TodoStore {
todos: Todo[];
addTodo: (todo: Todo) => void;
removeTodo: (id: string) => void;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
removeTodo: (id) => set((state) => ({
todos: state.todos.filter((t) => t.id !== id),
})),
}));
// ❌ AVOID: Implicit types (loses type safety)
export const useTodoStore = create((set) => ({
todos: [],
addTodo: (todo: any) => set((state: any) => ({ todos: [...state.todos, todo] })),
}));
Zustand vs Alternatives {#vs-alternatives}
| Aspect | Zustand | Redux | Context | Recoil |
|---|---|---|---|---|
| Bundle Size | 2KB | 12KB | Built-in | 40KB |
| Learning Curve | Very Easy | Steep | Easy | Moderate |
| Boilerplate | None | High | Medium | Medium |
| DevTools | Built-in | Excellent | No | Limited |
| Async | Built-in | Requires middleware | Use hooks | Built-in |
| Performance | Excellent | Good | Can cause re-renders | Good |
| Best For | Most apps | Complex enterprise | Simple state | Atom-based |
When to Use Zustand
✅ Use Zustand when:
- You want minimal boilerplate
- Building most modern React apps
- Combining multiple features
- Need good TypeScript support
- Want zero provider setup
❌ Consider alternatives when:
- Extremely large teams (Redux's structure helps)
- Need atom-based reactivity (Recoil)
- Only need simple local state (useState)
Real-World Examples {#examples}
Example 1: Authentication Store
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string;
role: 'user' | 'admin';
}
interface AuthStore {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
(set, get) => ({
user: null,
token: null,
isLoading: false,
error: null,
isAuthenticated: false,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const { user, token } = await response.json();
set({
user,
token,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
});
}
},
logout: () => {
set({
user: null,
token: null,
isAuthenticated: false,
});
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
),
{ name: 'Auth Store' }
)
);
// Usage
export function LoginForm() {
const { login, isLoading, error } = useAuthStore();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await login(
formData.get('email') as string,
formData.get('password') as string
);
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}
Example 2: Shopping Cart Store
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clear: () => void;
itemCount: () => number;
total: () => number;
}
export const useCartStore = create<CartStore>((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 + item.quantity }
: i
),
};
}
return { items: [...state.items, item] };
}),
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
).filter((i) => i.quantity > 0),
})),
clear: () => set({ items: [] }),
itemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
// Usage
export function CartSummary() {
const itemCount = useCartStore((state) => state.itemCount());
const total = useCartStore((state) => state.total());
return (
<div className="cart-summary">
<p>Items: {itemCount}</p>
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}
FAQ {#faq}
Q: Do I need a provider like Redux?
A: No! Zustand requires zero setup. Just create a store and use it directly in components.
Q: Is Zustand good for server state management?
A: Not recommended. Use React Query or SWR for server state. Zustand excels at client/UI state.
Q: How do I handle async operations?
A: Define async functions in your store actions. They automatically update state via set(). No middleware needed.
Q: Can I use Zustand with TypeScript?
A: Yes, full TypeScript support. Just define store type and actions get proper types.
Q: Should I have one store or multiple?
A: Multiple stores per feature (auth, ui, data, etc.). Easier to maintain than one monolithic store.
Q: How do I prevent unnecessary re-renders?
A: Use selective subscriptions with arrow functions: const count = useStore((state) => state.count). Only subscribes to that value.
Q: Can I use Zustand with Next.js?
A: Yes! Works great with Next.js App Router and Pages Router. Just create stores in separate files and import them.
Q: What's the difference between set() and direct mutation?
A: Always use set() for updates. Direct mutations won't trigger re-renders. Return new objects for immutability.
Ready to simplify your state management? Zustand's minimal API makes it perfect for projects of any size. Start with a single store and grow as your app evolves.
Next Steps: Explore React Query for server state, custom hooks for component logic, and TypeScript patterns for type safety.
Google AdSense Placeholder
CONTENT SLOT