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
Navbardisplaying the number of products in the cart. - A
ProductListallowing users to add products. - A
Cartdisplaying all selected products. - A
Checkoutcalculating 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>
);
}
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
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;
Let's break this down.
-
create()creates the store. -
countis the state we want to manage. -
set()updates the state. -
increment()anddecrement()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;
The interesting part is this line:
const count = useCounterStore((state) => state.count);
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;
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;
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;
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;
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'
}
)
);
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
useStatefor 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.
Top comments (0)