Introduction to Zustand
State management is one of the most critical aspects of building robust React applications. While React's built-in useState and useContext hooks are sufficient for simple applications, they often lead to performance bottlenecks and complex component trees as your app scales. Enter Zustand, a small, fast, and scalable bearbones state-management solution.
Zustand (which means "state" in German) was created to simplify global state management without the boilerplate of Redux. It uses a hook-based API, eliminates the need for context providers, and allows you to subscribe to specific slices of state to prevent unnecessary re-renders.
Getting Started with Zustand
To begin using Zustand, you first need to install it in your React project. You can do this via npm or yarn:
npm install zustand
# or
yarn add zustand
Basic Store Creation
Creating a store in Zustand is incredibly straightforward. You define your state and the actions that mutate it inside a single function passed to create.
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
export default useCounterStore;
Consuming State in Components
Because Zustand provides a custom hook, you can consume your state directly in any component without wrapping your application in a provider.
import React from 'react';
import useCounterStore from './useCounterStore';
function Counter() {
// Destructuring the state and actions directly
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
);
}
export default Counter;
Advanced Patterns in Zustand
Selectors and Performance Optimization
In the example above, the Counter component will re-render whenever any part of the store changes. To optimize performance, you should use selectors. Selectors allow a component to subscribe only to the specific slices of state it needs.
import React from 'react';
import useCounterStore from './useCounterStore';
function OptimizedCounter() {
// The component will only re-render if 'count' changes
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
</div>
);
}
If you need to select multiple state slices, you can pass a shallow equality function to prevent re-renders when the object reference changes but the values remain the same.
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
// Inside a component:
const { count, increment } = useCounterStore(
(state) => ({ count: state.count, increment: state.increment }),
shallow
);
Async Actions
Unlike Redux, which requires middleware like Redux Thunk or Saga to handle asynchronous operations, Zustand handles async actions natively. You simply call set whenever your promise resolves.
import { create } from 'zustand';
const useUserStore = create((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const data = await response.json();
set({ users: data, isLoading: false });
} catch (err) {
set({ error: err.message, isLoading: false });
}
},
}));
Zustand Ecosystem and Libraries
Zustand comes with a rich set of built-in middleware and integrates well with other popular libraries in the React ecosystem.
Using Immer for Immutable Updates
Updating nested state objects immutably can be verbose. By wrapping your store with the immer middleware, you can mutate state directly in a "draft" state, and Immer will handle the immutable update for you.
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useTodoStore = create(immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
// Mutating state directly thanks to Immer
state.todos.push({ id: Date.now(), text, completed: false });
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}),
})));
Persisting State
If you need to save your state to localStorage or sessionStorage, Zustand provides a persist middleware out of the box.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
}),
{
name: 'theme-storage', // unique name for localStorage key
}
)
);
Redux DevTools Integration
For debugging, you can connect your Zustand store to the Redux DevTools extension using the devtools middleware.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
// your state and actions here
}),
{ name: 'MyZustandStore' } // Name shown in DevTools
)
);
Best Practices
- Keep Stores Focused: Instead of putting all your global state into one massive store, create multiple smaller stores based on domain (e.g.,
useAuthStore,useCartStore,useUIStore). - Always Use Selectors: Avoid destructuring the entire store object in your components. Always use selector functions to subscribe only to the state your component actually needs.
- Colocate Actions with State: Define your actions inside the store alongside your state. This keeps logic centralized and makes your components purely presentational.
- Compose Middleware Carefully: When using multiple middlewares (e.g.,
persistanddevtools), ensure they are wrapped in the correct order. Usually,persistwrapsdevtools, which wraps your state creator.
Conclusion
Zustand has emerged as a powerful, ergonomic, and lightweight solution for state management in React. By stripping away the boilerplate of traditional state managers and embracing a hook-first API, it allows developers to build scalable applications with minimal friction. Whether you are managing simple UI toggles or complex asynchronous data flows, combining Zustand's core patterns with its ecosystem of middleware like Immer and Persist provides a highly productive developer experience. By adhering to best practices like using selectors and keeping stores domain-focused, you can ensure your application remains performant and maintainable as it grows.