Zustand is a tiny state management library for React that removes boilerplate and lets you write global state with plain hooks. If you are a full-stack developer tired of Redux ceremony, Zustand delivers a pragmatic middle ground between Context API pain and full-blown state machines.
I have used Zustand across production apps—from small dashboards to data-heavy admin panels—and it consistently beats the alternatives for 80% of use cases. The core idea is simple: you create a store with create(), and components subscribe to slices of it using hooks. No providers, no reducers, no action creators.
Why Zustand Matters (and When to Skip It)
Zustand matters because it solves the two biggest complaints about React state: re-render performance and boilerplate. With Context API, every value change re-renders every consumer. With Redux, you write actions, reducers, and selectors just to flip a boolean. Zustand gives you fine-grained subscriptions out of the box—components only re-render when their selected slice changes.
But skip Zustand if your state is heavily derived, needs time-travel debugging, or requires strict immutability guarantees across a large team. Redux Toolkit still wins there. Also skip it for server state—use TanStack Query or SWR for anything fetched from an API. Zustand is for client-side, synchronous, shared state like auth tokens, UI toggles, or cart items.
Getting Started with Zustand
Install it in one line:
npm install zustand
Create your first store:
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Use it in a component:
import { useCounterStore } from './store/counterStore';
export function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
That is the entire API surface. No Provider, no useSelector wrapper, no middleware juggling. The hook pattern is identical to useState but global.
Core Zustand Concepts Every Developer Should Know
1. Selectors prevent unnecessary re-renders
If you grab the whole store, every update re-renders your component. Always select the smallest slice you need:
// Bad: re-renders on any store change
const state = useCounterStore();
// Good: re-renders only when count changes
const count = useCounterStore((state) => state.count);
2. Actions live inside the store
Do not dispatch action objects. Define functions directly on the store. This keeps logic co-located and removes the action-type string soup:
interface UserStore {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const useUserStore = create<UserStore>((set) => ({
user: null,
login: async (email, password) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const user = await res.json();
set({ user });
},
logout: () => set({ user: null }),
}));
3. Middleware extends Zustand without pain
Persist state to localStorage with one line:
import { persist } from 'zustand/middleware';
export const useThemeStore = create(
persist(
(set) => ({
theme: 'dark',
toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
}),
{ name: 'theme-storage' }
)
);
The persist middleware handles serialization and rehydration automatically. You can also chain devtools for Redux DevTools integration—useful when you need to inspect state transitions.
Common Zustand Mistakes and How to Fix Them
Mistake 1: Creating stores inside components. Calling create() inside a component body creates a new store on every render, wiping state. Always define stores at module level, outside any component.
Mistake 2: Storing derived data. If you need filteredItems, compute it from items inside a selector or with useMemo. Storing derived values leads to sync bugs when the source changes.
// Wrong: storing derived data
const items = useStore((s) => s.items);
const filtered = useStore((s) => s.filteredItems);
// Right: derive at the call site
const items = useStore((s) => s.items);
const filtered = useMemo(() => items.filter((i) => i.active), [items]);
Mistake 3: Ignoring shallow equality for object selectors. Returning a new object from a selector triggers re-renders on every store change. Use useShallow for object-returning selectors:
import { useShallow } from 'zustand/react/shallow';
const { firstName, lastName } = useStore(
useShallow((s) => ({ firstName: s.firstName, lastName: s.lastName }))
);
When Should You Use Zustand?
Use Zustand when you have shared client-side state that multiple, unrelated components need to read and mutate—like a shopping cart, theme preferences, or an auth token. It is also ideal for state that changes frequently and would cause performance issues with Context API.
Do not use Zustand for server state (use TanStack Query), complex form state (use React Hook Form), or URL-driven state (keep that in the router). If your team already has deep Redux expertise and a mature toolchain, switching for switching's sake is not worth it.
Zustand in Production
Tip 1: Slice stores by domain. Instead of one giant store, create separate stores for auth, UI, and data. This keeps bundle size small (tree-shaking works per store) and makes debugging easier—you know exactly which store a bug lives in.
Tip 2: Use getState() outside React. For event handlers, API interceptors, or utility functions, call useStore.getState() to read or mutate state without subscribing:
// In an axios interceptor
import { useAuthStore } from './stores/authStore';
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
Tip 3: Profile before optimizing. Zustand is fast by default. If you have re-render issues, use React DevTools Profiler to confirm the source before adding selectors or splitting stores. Premature optimization adds complexity without measurable gain.
For more patterns and real-world examples, check out the work I have published on suhailroushan.com—there is a full project walkthrough that uses Zustand for a multi-tenant dashboard.
Your one concrete takeaway: replace your Context API state with a Zustand store today, start with selectors returning primitives, and keep all actions inside the store. That single shift will cut your re-render count and delete hundreds of lines of boilerplate.