Zustand vs Redux – Simplify React State Management in 2025

State is the backbone of any dynamic React app. But as your app grows, built-in tools like useState
and useContext
often fall short. That's where dedicated state management libraries come in. Redux has long been the default choice, but it's known for its boilerplate and steep learning curve. Enter Zustand — a lighter, hook-based alternative.
In this article, you'll learn how Zustand simplifies state management in React, how it compares to Redux, and how to build a small app using Zustand to manage global state with ease.
What Is Zustand?
Zustand (German for "state") is a small, fast, and scalable state-management solution built by the creators of Jotai and React Spring. It's unopinionated and leverages native React hooks to create centralized state stores with minimal boilerplate.
At just 3.5kB (minified and gzipped), Zustand is significantly smaller than Redux (which is around 6kB for the core library alone, not including Redux Toolkit). This lightweight footprint doesn't sacrifice functionality — Zustand provides everything you need for effective state management.
Key Features
- Minimal API — Learn the entire API in minutes, not hours
- Hook-based — Access state with a simple hook, no providers needed
- No boilerplate — No actions, action creators, dispatchers, or reducers required
- TypeScript ready — Excellent type inference out of the box
- Middleware support — Includes devtools, persistence, and more
- Transactional updates — Batch multiple state updates for better performance
- External store support — Use Zustand outside of React components
Zustand vs Redux: A Real-World Comparison
Let's compare Zustand and Redux by implementing the same feature: a shopping cart. This will highlight the practical differences between the two libraries.
Redux Implementation
Here's how you'd implement a shopping cart with Redux:
1// store/cartSlice.js
2import { createSlice } from '@reduxjs/toolkit';
3
4const cartSlice = createSlice({
5 name: 'cart',
6 initialState: {
7 items: [],
8 totalAmount: 0,
9 },
10 reducers: {
11 addItem: (state, action) => {
12 const newItem = action.payload;
13 const existingItem = state.items.find(item => item.id === newItem.id);
14
15 if (existingItem) {
16 existingItem.quantity += 1;
17 } else {
18 state.items.push({ ...newItem, quantity: 1 });
19 }
20
21 state.totalAmount = state.items.reduce(
22 (total, item) => total + (item.price * item.quantity),
23 0
24 );
25 },
26 removeItem: (state, action) => {
27 const id = action.payload;
28 const existingItem = state.items.find(item => item.id === id);
29
30 if (existingItem.quantity === 1) {
31 state.items = state.items.filter(item => item.id !== id);
32 } else {
33 existingItem.quantity -= 1;
34 }
35
36 state.totalAmount = state.items.reduce(
37 (total, item) => total + (item.price * item.quantity),
38 0
39 );
40 },
41 clearCart: (state) => {
42 state.items = [];
43 state.totalAmount = 0;
44 }
45 }
46});
47
48export const { addItem, removeItem, clearCart } = cartSlice.actions;
49export default cartSlice.reducer;
1// store/index.js
2import { configureStore } from '@reduxjs/toolkit';
3import cartReducer from './cartSlice';
4
5export const store = configureStore({
6 reducer: {
7 cart: cartReducer,
8 },
9});
1// index.js
2import React from 'react';
3import ReactDOM from 'react-dom';
4import { Provider } from 'react-redux';
5import { store } from './store';
6import App from './App';
7
8ReactDOM.render(
9 <Provider store={store}>
10 <App />
11 </Provider>,
12 document.getElementById('root')
13);
1// components/ProductItem.jsx
2import React from 'react';
3import { useDispatch } from 'react-redux';
4import { addItem } from '../store/cartSlice';
5
6function ProductItem({ product }) {
7 const dispatch = useDispatch();
8
9 const handleAddToCart = () => {
10 dispatch(addItem(product));
11 };
12
13 return (
14 <div className="product-item">
15 <h3>{product.title}</h3>
16 <p>${product.price.toFixed(2)}</p>
17 <button onClick={handleAddToCart}>Add to Cart</button>
18 </div>
19 );
20}
21
22export default ProductItem;
1// components/Cart.jsx
2import React from 'react';
3import { useSelector, useDispatch } from 'react-redux';
4import { removeItem, clearCart } from '../store/cartSlice';
5
6function Cart() {
7 const { items, totalAmount } = useSelector(state => state.cart);
8 const dispatch = useDispatch();
9
10 if (items.length === 0) {
11 return <div className="cart">Your cart is empty</div>;
12 }
13
14 return (
15 <div className="cart">
16 <h2>Your Cart</h2>
17 <ul>
18 {items.map(item => (
19 <li key={item.id}>
20 {item.title} - ${item.price.toFixed(2)} x {item.quantity}
21 <button onClick={() => dispatch(removeItem(item.id))}>
22 Remove
23 </button>
24 </li>
25 ))}
26 </ul>
27 <div className="total">
28 <span>Total Amount: ${totalAmount.toFixed(2)}</span>
29 <button onClick={() => dispatch(clearCart())}>Clear Cart</button>
30 </div>
31 </div>
32 );
33}
34
35export default Cart;
Zustand Implementation
Now, let's implement the same shopping cart with Zustand:
1// store/useCartStore.js
2import create from 'zustand';
3
4const useCartStore = create((set) => ({
5 items: [],
6 totalAmount: 0,
7
8 addItem: (product) => set((state) => {
9 const existingItem = state.items.find(item => item.id === product.id);
10
11 const updatedItems = existingItem
12 ? state.items.map(item =>
13 item.id === product.id
14 ? { ...item, quantity: item.quantity + 1 }
15 : item
16 )
17 : [...state.items, { ...product, quantity: 1 }];
18
19 const newTotalAmount = updatedItems.reduce(
20 (total, item) => total + (item.price * item.quantity),
21 0
22 );
23
24 return { items: updatedItems, totalAmount: newTotalAmount };
25 }),
26
27 removeItem: (id) => set((state) => {
28 const existingItem = state.items.find(item => item.id === id);
29
30 let updatedItems;
31 if (existingItem.quantity === 1) {
32 updatedItems = state.items.filter(item => item.id !== id);
33 } else {
34 updatedItems = state.items.map(item =>
35 item.id === id
36 ? { ...item, quantity: item.quantity - 1 }
37 : item
38 );
39 }
40
41 const newTotalAmount = updatedItems.reduce(
42 (total, item) => total + (item.price * item.quantity),
43 0
44 );
45
46 return { items: updatedItems, totalAmount: newTotalAmount };
47 }),
48
49 clearCart: () => set({ items: [], totalAmount: 0 }),
50}));
51
52export default useCartStore;
1// components/ProductItem.jsx
2import React from 'react';
3import useCartStore from '../store/useCartStore';
4
5function ProductItem({ product }) {
6 const addItem = useCartStore(state => state.addItem);
7
8 return (
9 <div className="product-item">
10 <h3>{product.title}</h3>
11 <p>${product.price.toFixed(2)}</p>
12 <button onClick={() => addItem(product)}>Add to Cart</button>
13 </div>
14 );
15}
16
17export default ProductItem;
1// components/Cart.jsx
2import React from 'react';
3import useCartStore from '../store/useCartStore';
4
5function Cart() {
6 const { items, totalAmount, removeItem, clearCart } = useCartStore();
7
8 if (items.length === 0) {
9 return <div className="cart">Your cart is empty</div>;
10 }
11
12 return (
13 <div className="cart">
14 <h2>Your Cart</h2>
15 <ul>
16 {items.map(item => (
17 <li key={item.id}>
18 {item.title} - ${item.price.toFixed(2)} x {item.quantity}
19 <button onClick={() => removeItem(item.id)}>
20 Remove
21 </button>
22 </li>
23 ))}
24 </ul>
25 <div className="total">
26 <span>Total Amount: ${totalAmount.toFixed(2)}</span>
27 <button onClick={clearCart}>Clear Cart</button>
28 </div>
29 </div>
30 );
31}
32
33export default Cart;
Key Differences
-
Setup Complexity:
- Redux requires setting up a store, slices, and wrapping your app in a Provider
- Zustand needs just a single store file, no providers needed
-
Code Volume:
- Redux: ~80 lines for the store setup
- Zustand: ~40 lines for the same functionality
-
Component Integration:
- Redux: Components need
useDispatch
anduseSelector
hooks - Zustand: A single hook gives access to both state and actions
- Redux: Components need
-
Learning Curve:
- Redux has concepts like actions, reducers, dispatch, and middleware
- Zustand has a single mental model: stores with state and functions
Real-World Use Case: Building a Task Management App
Let's build a more complete example: a task management app with Zustand. This will demonstrate how to handle complex state interactions, async operations, and middleware.
Setting Up the Store
1// store/useTaskStore.js
2import create from 'zustand';
3import { persist } from 'zustand/middleware';
4
5const useTaskStore = create(
6 persist(
7 (set, get) => ({
8 tasks: [],
9 filter: 'all', // 'all', 'active', 'completed'
10 loading: false,
11 error: null,
12
13 // Add a new task
14 addTask: (title) => set((state) => ({
15 tasks: [...state.tasks, {
16 id: Date.now().toString(),
17 title,
18 completed: false,
19 createdAt: new Date()
20 }]
21 })),
22
23 // Toggle task completion status
24 toggleTask: (id) => set((state) => ({
25 tasks: state.tasks.map(task =>
26 task.id === id
27 ? { ...task, completed: !task.completed }
28 : task
29 )
30 })),
31
32 // Delete a task
33 deleteTask: (id) => set((state) => ({
34 tasks: state.tasks.filter(task => task.id !== id)
35 })),
36
37 // Edit task title
38 editTask: (id, title) => set((state) => ({
39 tasks: state.tasks.map(task =>
40 task.id === id
41 ? { ...task, title }
42 : task
43 )
44 })),
45
46 // Change filter
47 setFilter: (filter) => set({ filter }),
48
49 // Clear completed tasks
50 clearCompleted: () => set((state) => ({
51 tasks: state.tasks.filter(task => !task.completed)
52 })),
53
54 // Fetch tasks from API
55 fetchTasks: async () => {
56 set({ loading: true, error: null });
57 try {
58 const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
59
60 const data = await response.json();
61
62 const formattedTasks = data.map(item => ({
63 id: item.id.toString(),
64 title: item.title,
65 completed: item.completed,
66 createdAt: new Date()
67 }));
68
69 set({ tasks: formattedTasks, loading: false });
70 } catch (error) {
71 set({ error: error.message, loading: false });
72 }
73 },
74
75 // Get filtered tasks
76 getFilteredTasks: () => {
77 const { tasks, filter } = get();
78
79 switch (filter) {
80 case 'active':
81 return tasks.filter(task => !task.completed);
82 case 'completed':
83 return tasks.filter(task => task.completed);
84 default:
85 return tasks;
86 }
87 },
88
89 // Get task stats
90 getStats: () => {
91 const { tasks } = get();
92 const total = tasks.length;
93 const completed = tasks.filter(task => task.completed).length;
94 const active = total - completed;
95
96 return { total, completed, active };
97 }
98 }),
99 {
100 name: 'task-storage', // unique name for localStorage
101 getStorage: () => localStorage, // (optional) by default, 'localStorage' is used
102 }
103 )
104);
105
106export default useTaskStore;
Building the UI Components
1// components/TaskInput.jsx
2import React, { useState } from 'react';
3import useTaskStore from '../store/useTaskStore';
4
5function TaskInput() {
6 const [title, setTitle] = useState('');
7 const addTask = useTaskStore(state => state.addTask);
8
9 const handleSubmit = (e) => {
10 e.preventDefault();
11 if (!title.trim()) return;
12
13 addTask(title);
14 setTitle('');
15 };
16
17 return (
18 <form onSubmit={handleSubmit} className="task-input">
19 <input
20 type="text"
21 value={title}
22 onChange={(e) => setTitle(e.target.value)}
23 placeholder="What needs to be done?"
24 />
25 <button type="submit">Add Task</button>
26 </form>
27 );
28}
29
30export default TaskInput;
1// components/TaskItem.jsx
2import React, { useState } from 'react';
3import useTaskStore from '../store/useTaskStore';
4
5function TaskItem({ task }) {
6 const [isEditing, setIsEditing] = useState(false);
7 const [editValue, setEditValue] = useState(task.title);
8
9 const toggleTask = useTaskStore(state => state.toggleTask);
10 const deleteTask = useTaskStore(state => state.deleteTask);
11 const editTask = useTaskStore(state => state.editTask);
12
13 const handleEdit = () => {
14 if (!editValue.trim()) return;
15
16 editTask(task.id, editValue);
17 setIsEditing(false);
18 };
19
20 return (
21 <li className={`task-item ${task.completed ? 'completed' : ''}`}>
22 {isEditing ? (
23 <div className="edit-mode">
24 <input
25 type="text"
26 value={editValue}
27 onChange={(e) => setEditValue(e.target.value)}
28 onBlur={handleEdit}
29 autoFocus
30 />
31 <button onClick={handleEdit}>Save</button>
32 </div>
33 ) : (
34 <div className="view-mode">
35 <input
36 type="checkbox"
37 checked={task.completed}
38 onChange={() => toggleTask(task.id)}
39 />
40 <span
41 className="title"
42 onDoubleClick={() => setIsEditing(true)}
43 >
44 {task.title}
45 </span>
46 <span className="date">
47 {new Date(task.createdAt).toLocaleDateString()}
48 </span>
49 <button onClick={() => deleteTask(task.id)}>Delete</button>
50 </div>
51 )}
52 </li>
53 );
54}
55
56export default TaskItem;
1// components/TaskList.jsx
2import React, { useEffect } from 'react';
3import useTaskStore from '../store/useTaskStore';
4import TaskItem from './TaskItem';
5
6function TaskList() {
7 const filteredTasks = useTaskStore(state => state.getFilteredTasks());
8 const loading = useTaskStore(state => state.loading);
9 const error = useTaskStore(state => state.error);
10 const fetchTasks = useTaskStore(state => state.fetchTasks);
11
12 useEffect(() => {
13 // Only fetch if we don't have any tasks yet
14 if (filteredTasks.length === 0) {
15 fetchTasks();
16 }
17 }, [fetchTasks, filteredTasks.length]);
18
19 if (loading) {
20 return <div className="loading">Loading tasks...</div>;
21 }
22
23 if (error) {
24 return <div className="error">Error: {error}</div>;
25 }
26
27 if (filteredTasks.length === 0) {
28 return <div className="empty-list">No tasks found</div>;
29 }
30
31 return (
32 <ul className="task-list">
33 {filteredTasks.map(task => (
34 <TaskItem key={task.id} task={task} />
35 ))}
36 </ul>
37 );
38}
39
40export default TaskList;
1// components/TaskFilters.jsx
2import React from 'react';
3import useTaskStore from '../store/useTaskStore';
4
5function TaskFilters() {
6 const filter = useTaskStore(state => state.filter);
7 const setFilter = useTaskStore(state => state.setFilter);
8 const stats = useTaskStore(state => state.getStats());
9 const clearCompleted = useTaskStore(state => state.clearCompleted);
10
11 return (
12 <div className="task-filters">
13 <div className="stats">
14 <span>{stats.active} items left</span>
15 </div>
16
17 <div className="filters">
18 <button
19 className={filter === 'all' ? 'active' : ''}
20 onClick={() => setFilter('all')}
21 >
22 All
23 </button>
24 <button
25 className={filter === 'active' ? 'active' : ''}
26 onClick={() => setFilter('active')}
27 >
28 Active
29 </button>
30 <button
31 className={filter === 'completed' ? 'active' : ''}
32 onClick={() => setFilter('completed')}
33 >
34 Completed
35 </button>
36 </div>
37
38 {stats.completed > 0 && (
39 <button
40 className="clear-completed"
41 onClick={clearCompleted}
42 >
43 Clear completed
44 </button>
45 )}
46 </div>
47 );
48}
49
50export default TaskFilters;
1// App.jsx
2import React from 'react';
3import TaskInput from './components/TaskInput';
4import TaskList from './components/TaskList';
5import TaskFilters from './components/TaskFilters';
6import useTaskStore from './store/useTaskStore';
7
8function App() {
9 const stats = useTaskStore(state => state.getStats());
10
11 return (
12 <div className="task-app">
13 <h1>Task Manager</h1>
14 <p className="stats-summary">
15 Total: {stats.total} | Active: {stats.active} | Completed: {stats.completed}
16 </p>
17
18 <TaskInput />
19 <TaskList />
20 <TaskFilters />
21 </div>
22 );
23}
24
25export default App;
Advanced Zustand Patterns
1. Combining Multiple Stores
While Redux encourages a single store, Zustand makes it easy to create multiple stores. This can be useful for separating concerns:
1// userStore.js
2import create from 'zustand';
3
4export const useUserStore = create((set) => ({
5 user: null,
6 loading: false,
7 error: null,
8
9 login: async (credentials) => {
10 set({ loading: true, error: null });
11 try {
12 // API call would go here
13 const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
14 set({ user, loading: false });
15 } catch (error) {
16 set({ error: error.message, loading: false });
17 }
18 },
19
20 logout: () => set({ user: null }),
21}));
22
23// themeStore.js
24import create from 'zustand';
25
26export const useThemeStore = create(
27 persist(
28 (set) => ({
29 theme: 'light',
30 toggleTheme: () => set((state) => ({
31 theme: state.theme === 'light' ? 'dark' : 'light'
32 })),
33 }),
34 { name: 'theme-storage' }
35 )
36);
2. Computed Values with Selectors
Zustand makes it easy to derive state with selectors:
1// Component using computed values
2function TaskSummary() {
3 // Only re-renders when the computed value changes
4 const completionPercentage = useTaskStore(state => {
5 const { total, completed } = state.getStats();
6 return total === 0 ? 0 : Math.round((completed / total) * 100);
7 });
8
9 return (
10 <div className="task-summary">
11 <div className="progress-bar">
12 <div
13 className="progress"
14 style={{ width: `${completionPercentage}%` }}
15 />
16 </div>
17 <span>{completionPercentage}% complete</span>
18 </div>
19 );
20}
3. Middleware for Logging
Zustand supports middleware for adding cross-cutting concerns:
1// store with logging middleware
2import create from 'zustand';
3
4// Custom middleware for logging
5const log = (config) => (set, get, api) => config(
6 (...args) => {
7 console.log('Applying state changes:', args);
8 set(...args);
9 console.log('New state:', get());
10 },
11 get,
12 api
13);
14
15const useLoggedStore = create(
16 log((set) => ({
17 count: 0,
18 increment: () => set((state) => ({ count: state.count + 1 })),
19 }))
20);
4. Async Actions with Loading States
Handle async operations elegantly:
1// Example of a store with async actions and loading states
2const useProductStore = create((set) => ({
3 products: [],
4 loading: false,
5 error: null,
6
7 fetchProducts: async (category) => {
8 set({ loading: true, error: null });
9
10 try {
11 const response = await fetch(`/api/products?category=${category}`);
12
13 if (!response.ok) {
14 throw new Error('Failed to fetch products');
15 }
16
17 const products = await response.json();
18 set({ products, loading: false });
19 } catch (error) {
20 set({ error: error.message, loading: false });
21 console.error('Error fetching products:', error);
22 }
23 }
24}));
25
26// Usage in a component
27function ProductList({ category }) {
28 const { products, loading, error, fetchProducts } = useProductStore();
29
30 useEffect(() => {
31 // Only fetch if we don't have any products yet
32 if (products.length === 0) {
33 fetchProducts(category);
34 }
35 }, [category, fetchProducts]);
36
37 if (loading) return <p>Loading products...</p>;
38 if (error) return <p>Error: {error}</p>;
39
40 return (
41 <div className="product-grid">
42 {products.map(product => (
43 <ProductCard key={product.id} product={product} />
44 ))}
45 </div>
46 );
47}
Performance Optimization
Zustand is designed to be efficient, but there are still ways to optimize performance:
1. Selective Subscriptions
Only subscribe to the parts of the state you need:
1// Bad: Component will re-render on ANY state change
2function Counter() {
3 const state = useCounterStore();
4 return <div>{state.count}</div>;
5}
6
7// Good: Component only re-renders when count changes
8function Counter() {
9 const count = useCounterStore(state => state.count);
10 return <div>{count}</div>;
11}
2. Memoized Selectors
For complex derived state, use memoization:
1import { useMemo } from 'react';
2
3function ExpensiveComponent() {
4 const items = useStore(state => state.items);
5
6 // This calculation only runs when items change
7 const processedItems = useMemo(() => {
8 return items.map(item => /* expensive calculation */);
9 }, [items]);
10
11 return (
12 <div>
13 {processedItems.map(item => (
14 <Item key={item.id} data={item} />
15 ))}
16 </div>
17 );
18}
3. Shallow Comparison
Zustand uses strict equality (===
) by default. For objects, use shallow comparison:
1import { shallow } from 'zustand/shallow';
2
3function UserProfile() {
4 // Only re-renders when firstName OR lastName change
5 const { firstName, lastName } = useUserStore(
6 state => ({
7 firstName: state.firstName,
8 lastName: state.lastName
9 }),
10 shallow // Use shallow equality
11 );
12
13 return <div>{firstName} {lastName}</div>;
14}
When to Choose Zustand Over Redux
Choose Zustand when:
- You want simplicity — Your team wants to avoid the Redux boilerplate and concepts
- You're building a small to medium app — Zustand's simplicity shines in less complex applications
- You prefer hooks — Your team is comfortable with React hooks and functional components
- You need quick setup — You want to get started with minimal configuration
- You value bundle size — You're concerned about adding unnecessary weight to your app
Choose Redux when:
- You need extensive middleware — Your app relies heavily on middleware for complex async flows
- You want a mature ecosystem — You need access to the wide range of Redux tools and extensions
- You prefer strict patterns — Your team benefits from Redux's opinionated structure
- You have a large team — The explicit nature of Redux can help with onboarding and maintenance
- You're building a complex app — Redux's architecture may scale better for very complex state needs
Conclusion
Zustand represents a significant step forward in React state management, offering a simpler alternative to Redux without sacrificing power or flexibility. Its hook-based API, minimal boilerplate, and intuitive design make it an excellent choice for modern React applications.
By embracing Zustand, you can:
- Reduce the complexity of your state management code
- Improve developer experience and productivity
- Create more maintainable applications
- Achieve better performance with less effort
Whether you're building a small personal project or a medium-sized application, Zustand provides the right balance of simplicity and power. And with its growing ecosystem and active community, it's well-positioned to be a leading state management solution in the React landscape for years to come.
Ready to simplify your state management? Give Zustand a try on your next project!