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:
// store/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
totalAmount: 0,
},
reducers: {
addItem: (state, action) => {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...newItem, quantity: 1 });
}
state.totalAmount = state.items.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
},
removeItem: (state, action) => {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);
if (existingItem.quantity === 1) {
state.items = state.items.filter(item => item.id !== id);
} else {
existingItem.quantity -= 1;
}
state.totalAmount = state.items.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
},
clearCart: (state) => {
state.items = [];
state.totalAmount = 0;
}
}
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
},
});
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
// components/ProductItem.jsx
import React from 'react';
import { useDispatch } from 'react-redux';
import { addItem } from '../store/cartSlice';
function ProductItem({ product }) {
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
return (
<div className="product-item">
<h3>{product.title}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={handleAddToCart}>Add to Cart</button>
</div>
);
}
export default ProductItem;
// components/Cart.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { removeItem, clearCart } from '../store/cartSlice';
function Cart() {
const { items, totalAmount } = useSelector(state => state.cart);
const dispatch = useDispatch();
if (items.length === 0) {
return <div className="cart">Your cart is empty</div>;
}
return (
<div className="cart">
<h2>Your Cart</h2>
<ul>
{items.map(item => (
<li key={item.id}>
{item.title} - ${item.price.toFixed(2)} x {item.quantity}
<button onClick={() => dispatch(removeItem(item.id))}>
Remove
</button>
</li>
))}
</ul>
<div className="total">
<span>Total Amount: ${totalAmount.toFixed(2)}</span>
<button onClick={() => dispatch(clearCart())}>Clear Cart</button>
</div>
</div>
);
}
export default Cart;
Zustand Implementation
Now, let's implement the same shopping cart with Zustand:
// store/useCartStore.js
import create from 'zustand';
const useCartStore = create((set) => ({
items: [],
totalAmount: 0,
addItem: (product) => set((state) => {
const existingItem = state.items.find(item => item.id === product.id);
const updatedItems = existingItem
? state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
: [...state.items, { ...product, quantity: 1 }];
const newTotalAmount = updatedItems.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
return { items: updatedItems, totalAmount: newTotalAmount };
}),
removeItem: (id) => set((state) => {
const existingItem = state.items.find(item => item.id === id);
let updatedItems;
if (existingItem.quantity === 1) {
updatedItems = state.items.filter(item => item.id !== id);
} else {
updatedItems = state.items.map(item =>
item.id === id
? { ...item, quantity: item.quantity - 1 }
: item
);
}
const newTotalAmount = updatedItems.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
return { items: updatedItems, totalAmount: newTotalAmount };
}),
clearCart: () => set({ items: [], totalAmount: 0 }),
}));
export default useCartStore;
// components/ProductItem.jsx
import React from 'react';
import useCartStore from '../store/useCartStore';
function ProductItem({ product }) {
const addItem = useCartStore(state => state.addItem);
return (
<div className="product-item">
<h3>{product.title}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
);
}
export default ProductItem;
// components/Cart.jsx
import React from 'react';
import useCartStore from '../store/useCartStore';
function Cart() {
const { items, totalAmount, removeItem, clearCart } = useCartStore();
if (items.length === 0) {
return <div className="cart">Your cart is empty</div>;
}
return (
<div className="cart">
<h2>Your Cart</h2>
<ul>
{items.map(item => (
<li key={item.id}>
{item.title} - ${item.price.toFixed(2)} x {item.quantity}
<button onClick={() => removeItem(item.id)}>
Remove
</button>
</li>
))}
</ul>
<div className="total">
<span>Total Amount: ${totalAmount.toFixed(2)}</span>
<button onClick={clearCart}>Clear Cart</button>
</div>
</div>
);
}
export 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
// store/useTaskStore.js
import create from 'zustand';
import { persist } from 'zustand/middleware';
const useTaskStore = create(
persist(
(set, get) => ({
tasks: [],
filter: 'all', // 'all', 'active', 'completed'
loading: false,
error: null,
// Add a new task
addTask: (title) => set((state) => ({
tasks: [...state.tasks, {
id: Date.now().toString(),
title,
completed: false,
createdAt: new Date()
}]
})),
// Toggle task completion status
toggleTask: (id) => set((state) => ({
tasks: state.tasks.map(task =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
})),
// Delete a task
deleteTask: (id) => set((state) => ({
tasks: state.tasks.filter(task => task.id !== id)
})),
// Edit task title
editTask: (id, title) => set((state) => ({
tasks: state.tasks.map(task =>
task.id === id
? { ...task, title }
: task
)
})),
// Change filter
setFilter: (filter) => set({ filter }),
// Clear completed tasks
clearCompleted: () => set((state) => ({
tasks: state.tasks.filter(task => !task.completed)
})),
// Fetch tasks from API
fetchTasks: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
const data = await response.json();
const formattedTasks = data.map(item => ({
id: item.id.toString(),
title: item.title,
completed: item.completed,
createdAt: new Date()
}));
set({ tasks: formattedTasks, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
// Get filtered tasks
getFilteredTasks: () => {
const { tasks, filter } = get();
switch (filter) {
case 'active':
return tasks.filter(task => !task.completed);
case 'completed':
return tasks.filter(task => task.completed);
default:
return tasks;
}
},
// Get task stats
getStats: () => {
const { tasks } = get();
const total = tasks.length;
const completed = tasks.filter(task => task.completed).length;
const active = total - completed;
return { total, completed, active };
}
}),
{
name: 'task-storage', // unique name for localStorage
getStorage: () => localStorage, // (optional) by default, 'localStorage' is used
}
)
);
export default useTaskStore;
Building the UI Components
// components/TaskInput.jsx
import React, { useState } from 'react';
import useTaskStore from '../store/useTaskStore';
function TaskInput() {
const [title, setTitle] = useState('');
const addTask = useTaskStore(state => state.addTask);
const handleSubmit = (e) => {
e.preventDefault();
if (!title.trim()) return;
addTask(title);
setTitle('');
};
return (
<form onSubmit={handleSubmit} className="task-input">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add Task</button>
</form>
);
}
export default TaskInput;
// components/TaskItem.jsx
import React, { useState } from 'react';
import useTaskStore from '../store/useTaskStore';
function TaskItem({ task }) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(task.title);
const toggleTask = useTaskStore(state => state.toggleTask);
const deleteTask = useTaskStore(state => state.deleteTask);
const editTask = useTaskStore(state => state.editTask);
const handleEdit = () => {
if (!editValue.trim()) return;
editTask(task.id, editValue);
setIsEditing(false);
};
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
{isEditing ? (
<div className="edit-mode">
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleEdit}
autoFocus
/>
<button onClick={handleEdit}>Save</button>
</div>
) : (
<div className="view-mode">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span
className="title"
onDoubleClick={() => setIsEditing(true)}
>
{task.title}
</span>
<span className="date">
{new Date(task.createdAt).toLocaleDateString()}
</span>
<button onClick={() => deleteTask(task.id)}>Delete</button>
</div>
)}
</li>
);
}
export default TaskItem;
// components/TaskList.jsx
import React, { useEffect } from 'react';
import useTaskStore from '../store/useTaskStore';
import TaskItem from './TaskItem';
function TaskList() {
const filteredTasks = useTaskStore(state => state.getFilteredTasks());
const loading = useTaskStore(state => state.loading);
const error = useTaskStore(state => state.error);
const fetchTasks = useTaskStore(state => state.fetchTasks);
useEffect(() => {
// Only fetch if we don't have any tasks yet
if (filteredTasks.length === 0) {
fetchTasks();
}
}, [fetchTasks, filteredTasks.length]);
if (loading) {
return <div className="loading">Loading tasks...</div>;
}
if (error) {
return <div className="error">Error: {error}</div>;
}
if (filteredTasks.length === 0) {
return <div className="empty-list">No tasks found</div>;
}
return (
<ul className="task-list">
{filteredTasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
export default TaskList;
// components/TaskFilters.jsx
import React from 'react';
import useTaskStore from '../store/useTaskStore';
function TaskFilters() {
const filter = useTaskStore(state => state.filter);
const setFilter = useTaskStore(state => state.setFilter);
const stats = useTaskStore(state => state.getStats());
const clearCompleted = useTaskStore(state => state.clearCompleted);
return (
<div className="task-filters">
<div className="stats">
<span>{stats.active} items left</span>
</div>
<div className="filters">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={filter === 'active' ? 'active' : ''}
onClick={() => setFilter('active')}
>
Active
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => setFilter('completed')}
>
Completed
</button>
</div>
{stats.completed > 0 && (
<button
className="clear-completed"
onClick={clearCompleted}
>
Clear completed
</button>
)}
</div>
);
}
export default TaskFilters;
// App.jsx
import React from 'react';
import TaskInput from './components/TaskInput';
import TaskList from './components/TaskList';
import TaskFilters from './components/TaskFilters';
import useTaskStore from './store/useTaskStore';
function App() {
const stats = useTaskStore(state => state.getStats());
return (
<div className="task-app">
<h1>Task Manager</h1>
<p className="stats-summary">
Total: {stats.total} | Active: {stats.active} | Completed: {stats.completed}
</p>
<TaskInput />
<TaskList />
<TaskFilters />
</div>
);
}
export 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:
// userStore.js
import create from 'zustand';
export const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
login: async (credentials) => {
set({ loading: true, error: null });
try {
// API call would go here
const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
set({ user, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
logout: () => set({ user: null }),
}));
// themeStore.js
import create from 'zustand';
export const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
}),
{ name: 'theme-storage' }
)
);
2. Computed Values with Selectors
Zustand makes it easy to derive state with selectors:
// Component using computed values
function TaskSummary() {
// Only re-renders when the computed value changes
const completionPercentage = useTaskStore(state => {
const { total, completed } = state.getStats();
return total === 0 ? 0 : Math.round((completed / total) * 100);
});
return (
<div className="task-summary">
<div className="progress-bar">
<div
className="progress"
style={{ width: `${completionPercentage}%` }}
/>
</div>
<span>{completionPercentage}% complete</span>
</div>
);
}
3. Middleware for Logging
Zustand supports middleware for adding cross-cutting concerns:
// store with logging middleware
import create from 'zustand';
// Custom middleware for logging
const log = (config) => (set, get, api) => config(
(...args) => {
console.log('Applying state changes:', args);
set(...args);
console.log('New state:', get());
},
get,
api
);
const useLoggedStore = create(
log((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);
4. Async Actions with Loading States
Handle async operations elegantly:
// Example of a store with async actions and loading states
const useProductStore = create((set) => ({
products: [],
loading: false,
error: null,
fetchProducts: async (category) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/products?category=${category}`);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const products = await response.json();
set({ products, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
console.error('Error fetching products:', error);
}
}
}));
// Usage in a component
function ProductList({ category }) {
const { products, loading, error, fetchProducts } = useProductStore();
useEffect(() => {
// Only fetch if we don't have any products yet
if (products.length === 0) {
fetchProducts(category);
}
}, [category, fetchProducts]);
if (loading) return <p>Loading products...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
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:
// Bad: Component will re-render on ANY state change
function Counter() {
const state = useCounterStore();
return <div>{state.count}</div>;
}
// Good: Component only re-renders when count changes
function Counter() {
const count = useCounterStore(state => state.count);
return <div>{count}</div>;
}
2. Memoized Selectors
For complex derived state, use memoization:
import { useMemo } from 'react';
function ExpensiveComponent() {
const items = useStore(state => state.items);
// This calculation only runs when items change
const processedItems = useMemo(() => {
return items.map(item => /* expensive calculation */);
}, [items]);
return (
<div>
{processedItems.map(item => (
<Item key={item.id} data={item} />
))}
</div>
);
}
3. Shallow Comparison
Zustand uses strict equality (===
) by default. For objects, use shallow comparison:
import { shallow } from 'zustand/shallow';
function UserProfile() {
// Only re-renders when firstName OR lastName change
const { firstName, lastName } = useUserStore(
state => ({
firstName: state.firstName,
lastName: state.lastName
}),
shallow // Use shallow equality
);
return <div>{firstName} {lastName}</div>;
}
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!