Zustand vs Redux – Simplify React State Management in 2025


Never miss an update
Subscribe to receive news and special offers.
By subscribing you agree to our Privacy Policy.
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.
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.
Let's compare Zustand and Redux by implementing the same feature: a shopping cart. This will highlight the practical differences between the two libraries.
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;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;Setup Complexity:
Code Volume:
Component Integration:
useDispatch and useSelector hooksLearning Curve:
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.
// 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;// 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;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' }
)
);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>
);
}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 })),
}))
);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>
);
}Zustand is designed to be efficient, but there are still ways to optimize performance:
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>;
}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>
);
}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>;
}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:
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!