Zustand Tutorial: Lightweight State Management for React
Tuesday, Dec 23, 2025
If you’ve ever used Redux, you know how complicated the setup can be: reducers, actions, action types, middleware… Not to mention the boilerplate that needs to be written repeatedly.
Zustand comes as a much simpler alternative. The name “Zustand” comes from German meaning “state”. And as the name suggests, this library focuses on one thing: straightforward state management.
Why Do We Need State Management?
Before discussing Zustand, let’s understand why state management is important.
In React, passing data between components uses props. But as applications get more complex, you’ll encounter the prop drilling problem:
// Prop drilling nightmare 😱
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} />;
}
function Sidebar({ user, setUser }) {
return <UserProfile user={user} setUser={setUser} />;
}
function UserProfile({ user, setUser }) {
// Finally arrived...
return <div>{user?.name}</div>;
}
State management libraries help you share state to any component without passing props continuously.
What is Zustand?
Zustand is a minimalist but powerful state management library. Created by the same team behind Jotai and React Spring (Poimandres).
// Zustand store in 5 lines
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
That’s it. No Provider, no reducer, no action type constants.
Why Is Zustand Simpler Than Redux?
| Aspect | Redux | Zustand |
|---|---|---|
| Setup | Store + Reducers + Actions + Provider | Single create() function |
| Boilerplate | Many files and constants | Minimal, can be 1 file |
| Provider | Must wrap with Provider | No Provider needed |
| Learning Curve | Steep | Very easy |
| Bundle Size | ~7KB (Redux + React-Redux) | ~1KB |
| DevTools | Requires middleware | Built-in support |
Installation and Setup
1. Install Package
npm install zustand
# or
pnpm add zustand
# or
yarn add zustand
Done. No additional setup. No need to wrap application with Provider.
2. Recommended Project Structure
src/
├── stores/
│ ├── useUserStore.ts
│ ├── useCartStore.ts
│ └── useThemeStore.ts
├── components/
└── ...
Creating Your First Store
Let’s create a simple counter store:
// filepath: /src/stores/useCounterStore.ts
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 }),
}));
Code Explanation
create<CounterState>- Creates store with TypeScript typeset- Function to update state(state) => ({ count: state.count + 1 })- Update based on previous stateset({ count: 0 })- Direct update without reference to previous state
Using Store in Components
// filepath: /src/components/Counter.tsx
import { useCounterStore } from '../stores/useCounterStore';
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div className="flex flex-col items-center gap-4">
<h1 className="text-4xl font-bold">{count}</h1>
<div className="flex gap-2">
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+</button>
</div>
</div>
);
}
Selectors for Performance Optimization
If you only need part of the state, use selectors:
// ❌ This will re-render every time ANY state changes
const state = useCounterStore();
// ✅ This only re-renders when `count` changes
const count = useCounterStore((state) => state.count);
// ✅ Get multiple values at once
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}));
Actions and State Updates
More Complex Store Example
// filepath: /src/stores/useTodoStore.ts
import { create } from 'zustand';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
setFilter: (filter: 'all' | 'active' | 'completed') => void;
clearCompleted: () => void;
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{ id: crypto.randomUUID(), text, completed: false },
],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
setFilter: (filter) => set({ filter }),
clearCompleted: () =>
set((state) => ({
todos: state.todos.filter((todo) => !todo.completed),
})),
}));
Computed Values with get
// filepath: /src/stores/useTodoStore.ts
export const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
filter: 'all',
// Getter for filtered todos
getFilteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed);
case 'completed':
return todos.filter((t) => t.completed);
default:
return todos;
}
},
// Getter for count
getActiveCount: () => {
return get().todos.filter((t) => !t.completed).length;
},
// ... other actions
}));
Async Actions (API Calls)
Zustand supports async actions natively without additional middleware:
// filepath: /src/stores/useProductStore.ts
import { create } from 'zustand';
interface Product {
id: number;
name: string;
price: number;
}
interface ProductState {
products: Product[];
isLoading: boolean;
error: string | null;
fetchProducts: () => Promise<void>;
createProduct: (product: Omit<Product, 'id'>) => Promise<void>;
}
export const useProductStore = create<ProductState>((set, get) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed to fetch');
const products = await res.json();
set({ products, isLoading: false });
} catch (error) {
set({ error: (error as Error).message, isLoading: false });
}
},
createProduct: async (product) => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error('Failed to create');
const newProduct = await res.json();
set((state) => ({
products: [...state.products, newProduct],
isLoading: false,
}));
} catch (error) {
set({ error: (error as Error).message, isLoading: false });
}
},
}));
Using Async Store
// filepath: /src/components/ProductList.tsx
import { useEffect } from 'react';
import { useProductStore } from '../stores/useProductStore';
export function ProductList() {
const { products, isLoading, error, fetchProducts } = useProductStore();
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price.toLocaleString()}
</li>
))}
</ul>
);
}
Persist State to localStorage
Zustand has a persist middleware for saving state to localStorage:
// filepath: /src/stores/useThemeStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ThemeState {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{
name: 'theme-storage', // Key in localStorage
}
)
);
Complete Persist Options
// filepath: /src/stores/useUserStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface UserState {
user: { name: string; email: string } | null;
token: string | null;
login: (user: { name: string; email: string }, token: string) => void;
logout: () => void;
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}),
{
name: 'user-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
user: state.user,
token: state.token,
}),
}
)
);
DevTools Integration
Zustand supports Redux DevTools for debugging:
// filepath: /src/stores/useCounterStore.ts
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' // Action name in DevTools
),
decrement: () =>
set(
(state) => ({ count: state.count - 1 }),
false,
'decrement'
),
}),
{ name: 'CounterStore' }
)
);
Combining Multiple Middleware
// filepath: /src/stores/useCartStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
clearCart: () => void;
getTotalPrice: () => number;
}
export const useCartStore = create<CartState>()(
devtools(
persist(
(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 }] };
},
false,
'addItem'
),
removeItem: (id) =>
set(
(state) => ({
items: state.items.filter((i) => i.id !== id),
}),
false,
'removeItem'
),
clearCart: () => set({ items: [] }, false, 'clearCart'),
getTotalPrice: () => {
return get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
}),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
Zustand vs Redux vs Jotai
| Feature | Zustand | Redux Toolkit | Jotai |
|---|---|---|---|
| Bundle Size | ~1KB | ~7KB | ~3KB |
| Boilerplate | Minimal | Medium | Minimal |
| Learning Curve | Easy | Medium | Easy |
| DevTools | ✅ | ✅ | ✅ |
| Persist | ✅ Middleware | ✅ Middleware | ✅ Atoms |
| Async | Native | RTK Query / Thunk | Native |
| Provider | ❌ Not needed | ✅ Required | ✅ Optional |
| Atom-based | ❌ | ❌ | ✅ |
| TypeScript | ✅ Excellent | ✅ Good | ✅ Excellent |
When to Use Zustand?
- Medium-size applications with straightforward state
- Teams that want to escape Redux complexity
- Need quick setup without boilerplate
- State management for side projects or MVPs
When to Use Redux Toolkit?
- Enterprise applications already using Redux
- Need RTK Query for data fetching
- Team already familiar with Redux patterns
When to Use Jotai?
- Applications with many independent state atoms
- Prefer bottom-up state composition
- Need fine-grained reactivity
Best Practices
1. Separate Stores by Domain
// ❌ Don't combine everything in one store
const useStore = create((set) => ({
user: null,
cart: [],
theme: 'light',
notifications: [],
// ... too many
}));
// ✅ Separate by domain
const useUserStore = create((set) => ({ ... }));
const useCartStore = create((set) => ({ ... }));
const useThemeStore = create((set) => ({ ... }));
2. Use Selectors for Performance
// ❌ Component re-renders every time state changes
function Component() {
const store = useStore();
return <div>{store.count}</div>;
}
// ✅ Only re-renders when count changes
function Component() {
const count = useStore((state) => state.count);
return <div>{count}</div>;
}
3. Immer for Nested State
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface State {
user: {
profile: {
name: string;
settings: {
notifications: boolean;
};
};
};
updateNotificationSetting: (value: boolean) => void;
}
const useStore = create<State>()(
immer((set) => ({
user: {
profile: {
name: 'John',
settings: {
notifications: true,
},
},
},
updateNotificationSetting: (value) =>
set((state) => {
state.user.profile.settings.notifications = value;
}),
}))
);
4. Testing Store
// filepath: /src/stores/__tests__/useCounterStore.test.ts
import { useCounterStore } from '../useCounterStore';
describe('useCounterStore', () => {
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});
it('should increment count', () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});
it('should decrement count', () => {
useCounterStore.setState({ count: 5 });
useCounterStore.getState().decrement();
expect(useCounterStore.getState().count).toBe(4);
});
it('should reset count', () => {
useCounterStore.setState({ count: 10 });
useCounterStore.getState().reset();
expect(useCounterStore.getState().count).toBe(0);
});
});
5. Subscribe to State Changes
// Subscribe outside React
const unsubscribe = useStore.subscribe(
(state) => state.count,
(count, previousCount) => {
console.log('Count changed from', previousCount, 'to', count);
}
);
// Cleanup
unsubscribe();
Conclusion
Zustand is a refreshing state management library in the React ecosystem. With a minimal but powerful API, you can manage state without dealing with excessive boilerplate.
Key takeaways:
- Simple API: Just
create()and ready to use - No Provider: No need to wrap application
- TypeScript First: Excellent type inference
- Middleware: Persist, DevTools, Immer built-in
- Lightweight: Only ~1KB gzipped
If you’re still using Redux and feel overwhelmed by the boilerplate, give Zustand a try. You might be surprised how easy state management can be.
Happy coding! 🐻