Zustand Tutorial: Lightweight State Management for React
Rabu, 15 Jan 2025
If you’ve ever used Redux, you know how tedious the setup can be: reducers, actions, action types, middleware… Not to mention the boilerplate that has to be written over and over again.
Zustand comes as a much simpler alternative. The name “Zustand” comes from German, meaning “state”. And true to its name, this library focuses on one thing: straightforward state management.
Why Do You 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 continuously passing props.
What is Zustand?
Zustand is a minimalist yet 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 | Needs 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 the 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 a store with TypeScript typeset- Function to update state(state) => ({ count: state.count + 1 })- Update based on previous stateset({ count: 0 })- Direct update without referencing previous state
Using the 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>
);
}
Selector for Performance Optimization
If you only need part of the state, use a selector:
// ❌ 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
Zustand supports async actions natively:
// filepath: /src/stores/useUserStore.ts
import { create } from 'zustand';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: (id: string) => Promise<void>;
logout: () => void;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
logout: () => set({ user: null }),
}));
Middleware: Persist, DevTools, Immer
Persist Middleware
Save 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' }
)
);
DevTools Middleware
Debug with Redux DevTools:
// 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 that already use Redux
- Need RTK Query for data fetching
- Teams 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 things
}));
// ✅ 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 Stores
// 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 of 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 yet powerful API, you can manage state without dealing with excessive boilerplate.
Key takeaways:
- Simple API: Just
create()and you’re ready to go - No Provider: No need to wrap the application
- TypeScript First: Excellent type inference
- Middleware: Persist, DevTools, Immer built-in
- Lightweight: Only ~1KB gzipped
If you’re still using Redux and feeling overwhelmed by the boilerplate, give Zustand a try. You might be surprised how easy state management can be.
Happy coding!