Zustand vs Redux – Simplify React State Management in 2025

npmixnpmix
16
Zustand vs Redux

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:

jsx
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;
jsx
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});
jsx
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);
jsx
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;
jsx
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:

jsx
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;
jsx
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;
jsx
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

  1. 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
  2. Code Volume:

    • Redux: ~80 lines for the store setup
    • Zustand: ~40 lines for the same functionality
  3. Component Integration:

    • Redux: Components need useDispatch and useSelector hooks
    • Zustand: A single hook gives access to both state and actions
  4. 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

jsx
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

jsx
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;
jsx
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;
jsx
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;
jsx
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;
jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

  1. You want simplicity — Your team wants to avoid the Redux boilerplate and concepts
  2. You're building a small to medium app — Zustand's simplicity shines in less complex applications
  3. You prefer hooks — Your team is comfortable with React hooks and functional components
  4. You need quick setup — You want to get started with minimal configuration
  5. You value bundle size — You're concerned about adding unnecessary weight to your app

Choose Redux when:

  1. You need extensive middleware — Your app relies heavily on middleware for complex async flows
  2. You want a mature ecosystem — You need access to the wide range of Redux tools and extensions
  3. You prefer strict patterns — Your team benefits from Redux's opinionated structure
  4. You have a large team — The explicit nature of Redux can help with onboarding and maintenance
  5. 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!

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.