DEV Community

ghassen khaldi
ghassen khaldi

Posted on Edited on

Zustand: State Management Without the Redux Boilerplate

As React applications grow, managing state becomes more challenging.

At first, using useState is enough. But what happens when multiple components need access to the same data? You might find yourself passing props through several components just to share a single value.

This is known as prop drilling, and it can make your application harder to maintain.

State management libraries solve this problem. One popular solution is Zustand: a lightweight state management library for React that makes sharing state between components surprisingly simple.

Let's explore how it works and why you might want to use it.

1. The problem: prop drilling

Imagine you're building an e-commerce application.

You have:

  • A Navbar displaying the number of products in the cart.
  • A ProductList allowing users to add products.
  • A Cart displaying all selected products.
  • A Checkout calculating the total price.

All these components need access to the same cart data.

Without a shared state solution, you might have to pass the cart through multiple components:

function App() {
  const [cart, setCart] = useState([]);

  return (
    <Navbar cart={cart}>
      <ProductList cart={cart} />
      <Cart cart={cart} />
    </Navbar>
  );
}
Enter fullscreen mode Exit fullscreen mode

This can quickly become difficult to manage, especially when your component tree gets deeper.

So, instead of passing data between components, what if they could access the same state directly?

That's where Zustand comes in.

2. What is Zustand?

Zustand is a small state management library for React.

It allows you to create a centralized store containing your application's state and the functions responsible for updating it.

Any component can access the store without receiving props from its parent.

The idea is simple:

  • Create a store.
  • Put your state and actions inside it.
  • Access them from any component using a hook.

No providers wrapping your application, no reducers required, and very little boilerplate.

3. Getting started

First, install Zustand:

npm install zustand
Enter fullscreen mode Exit fullscreen mode

Let's create a simple counter store.

counterStore.js

import { create } from 'zustand';

const useCounterStore = create((set) => ({
  count: 0,

  increment: () =>
    set((state) => ({
      count: state.count + 1
    })),

  decrement: () =>
    set((state) => ({
      count: state.count - 1
    }))
}));

export default useCounterStore;
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

  • create() creates the store.
  • count is the state we want to manage.
  • set() updates the state.
  • increment() and decrement() are actions that modify it.

Notice that we don't need to create separate reducers or action types. We simply define what the state contains and how it can change.

4. Using the store in React components

Now that we have our store, let's use it in different components.

Counter.jsx

import useCounterStore from './counterStore';

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore(
    (state) => state.increment
  );
  const decrement = useCounterStore(
    (state) => state.decrement
  );

  return (
    <div>
      <h2>Count: {count}</h2>

      <button onClick={increment}>
        +
      </button>

      <button onClick={decrement}>
        -
      </button>
    </div>
  );
}

export default Counter;
Enter fullscreen mode Exit fullscreen mode

The interesting part is this line:

const count = useCounterStore((state) => state.count);
Enter fullscreen mode Exit fullscreen mode

We're selecting the count value directly from the store.

We don't need to pass it as a prop or wrap the component in a provider.

Another component can access the same state using the same store:

import useCounterStore from './counterStore';

function Navbar() {
  const count = useCounterStore((state) => state.count);

  return <h2>Items: {count}</h2>;
}

export default Navbar;
Enter fullscreen mode Exit fullscreen mode

Whenever the count changes, both components receive the updated value.

An important detail: Zustand uses selectors to subscribe components to specific parts of the store. This helps avoid unnecessary re-renders when unrelated state changes.

If you select multiple values by returning an object, consider using Zustand's useShallow to avoid re-rendering when the selected values are unchanged.

5. A more realistic example: Shopping cart

A counter is useful for understanding the basics, but let's see how Zustand can help in a real application.

We'll create a shopping cart store with three actions:

  • Add a product.
  • Remove a product.
  • Clear the cart.

cartStore.js

import { create } from 'zustand';

const useCartStore = create((set) => ({
  cart: [],

  addToCart: (product) =>
    set((state) => ({
      cart: [...state.cart, product]
    })),

  removeFromCart: (id) =>
    set((state) => ({
      cart: state.cart.filter(
        (item) => item.id !== id
      )
    })),

  clearCart: () => set({ cart: [] })
}));

export default useCartStore;
Enter fullscreen mode Exit fullscreen mode

Now, any component can interact with the cart.

Product.jsx

import useCartStore from './cartStore';

function Product({ product }) {
  const addToCart = useCartStore(
    (state) => state.addToCart
  );

  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>

      <button onClick={() => addToCart(product)}>
        Add to cart
      </button>
    </div>
  );
}

export default Product;
Enter fullscreen mode Exit fullscreen mode

Cart.jsx

import useCartStore from './cartStore';

function Cart() {
  const cart = useCartStore((state) => state.cart);
  const removeFromCart = useCartStore(
    (state) => state.removeFromCart
  );
  const clearCart = useCartStore(
    (state) => state.clearCart
  );

  const total = cart.reduce(
    (sum, item) => sum + item.price,
    0
  );

  return (
    <div>
      <h2>Your Cart</h2>

      {cart.map((item) => (
        <div key={item.id}>
          <span>{item.name}</span>

          <button onClick={() => removeFromCart(item.id)}>
            Remove
          </button>
        </div>
      ))}

      <h3>Total: ${total}</h3>

      <button onClick={clearCart}>
        Clear cart
      </button>
    </div>
  );
}

export default Cart;
Enter fullscreen mode Exit fullscreen mode

Now, the Product component can add items, while the Cart component can display and remove them.

Neither component needs to know how the other one works.

They simply communicate through the shared store.

Note: This simplified example adds a new entry every time a product is added. A production cart would usually check whether the product already exists and update its quantity.

6. Zustand vs Redux Toolkit

You might have heard of Redux, another popular state management solution.

Both Zustand and Redux Toolkit help manage shared application state, but they approach the problem differently.

Feature Zustand Redux Toolkit
Setup Minimal More structured
Boilerplate Very little More concepts and structure
State updates Functions inside the store Reducers and actions
React integration Hooks React-Redux hooks
Middleware Supported Supported
Best suited for Lightweight shared state Structured, complex state management

Redux Toolkit provides conventions and structure that can be valuable in larger applications and teams.

Zustand offers a simpler approach when you want shared state without introducing as many concepts.

Neither is universally better. The right choice depends on your application's requirements.

7. Useful Zustand features

Zustand offers more than just basic state management.

Here are a few features worth exploring:

Persist middleware

By default, store state lives in memory. If the page reloads, that state is lost.

The persist middleware allows you to save state in storage such as localStorage.

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCounterStore = create(
  persist(
    (set) => ({
      count: 0,

      increment: () =>
        set((state) => ({
          count: state.count + 1
        }))
    }),
    {
      name: 'counter-storage'
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

Now, the counter can survive a page refresh.

DevTools middleware

Zustand can integrate with Redux DevTools, making it easier to inspect state changes and debug your application.

Immer integration

For more complex nested state, Immer can make updates easier to write using a mutable-looking syntax while preserving immutable state updates.

These features allow Zustand to remain simple while supporting more advanced use cases.

8. When should you use Zustand?

Zustand is particularly useful when multiple components need access to the same client-side state.

Examples include:

  • Shopping carts.
  • Theme preferences.
  • UI state such as modals and sidebars.
  • Filters and sorting preferences.
  • Multi-step forms.
  • Shared application settings.

However, not every piece of state needs Zustand.

For example:

  • Use useState for simple state that belongs to one component.
  • Use Zustand for shared client-side state.
  • Use TanStack Query for fetching, caching, and synchronizing server data.
  • Use authentication solutions such as Clerk when you need a complete authentication system.

Choosing the right tool for the right problem is just as important as knowing how to use it.

Conclusion

Zustand makes global state management in React easier by removing much of the boilerplate associated with more traditional solutions.

Its simple API, lightweight setup, and selector-based subscriptions make it a great option for applications that need shared state without unnecessary complexity.

If you're working on a React project and find yourself passing props through several components, Zustand is definitely worth exploring.

Sometimes, the best solution isn't adding more complexity. It's finding a simpler way to solve the problem.

Resources

Top comments (0)