Zustand vs Redux – Simplify React State Management in 2025

npmixnpmix
126
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
// 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;
jsx
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
});
jsx
// 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')
);
jsx
// 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;
jsx
// 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:

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

  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
// 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

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

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

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

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

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

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

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

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

  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.