Most React state-management pain is self-inflicted: a Context that re-renders half the tree, or a Redux setup with three files per feature. Zustand is the antidote — a tiny (~1KB) store you create with a hook, no providers, no boilerplate. This is the practical introduction I give anyone picking it up.
Create a store
A store is a hook. You call create with a function that receives set and returns your state plus the actions that change it.
import { create } from "zustand";
type CounterState = {
count: number;
increment: () => void;
reset: () => void;
};
export const useCounter = create<CounterState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));Use it in a component
No provider needed — just call the hook anywhere.
function Counter() {
const count = useCounter((s) => s.count);
const increment = useCounter((s) => s.increment);
return <button onClick={increment}>Count: {count}</button>;
}Always select the SMALLEST slice you need with a selector — useCounter((s) => s.count) — not the whole store. A component only re-renders when its selected value changes, which is how Zustand stays fast.
The re-render trap
// ❌ re-renders on ANY store change — you grabbed everything
const { count } = useCounter();
// ✅ re-renders only when count changes
const count = useCounter((s) => s.count);Selecting multiple values? Return them and tell Zustand to compare shallowly, or select each separately — otherwise a new object every render defeats the optimisation.
import { useShallow } from "zustand/react/shallow";
const { count, increment } = useCounter(
useShallow((s) => ({ count: s.count, increment: s.increment })),
);Async actions
Actions can be async — set works the same before and after the await.
type UserState = {
user: User | null;
loading: boolean;
fetchUser: (id: string) => Promise<void>;
};
export const useUser = create<UserState>((set) => ({
user: null,
loading: false,
fetchUser: async (id) => {
set({ loading: true });
const user = await api.getUser(id);
set({ user, loading: false });
},
}));Persist to localStorage
The persist middleware saves and rehydrates your store with no extra wiring.
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useSettings = create(
persist<{ theme: string; setTheme: (t: string) => void }>(
(set) => ({
theme: "dark",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings" }, // localStorage key
),
);When to use Zustand (and when not to)
- Reach for it when state is shared across distant components and Context would re-render too much, or Redux is overkill.
- Keep SERVER data (fetched from an API) in a data-fetching library like TanStack Query — Zustand is for CLIENT state (UI, session, preferences), not a cache.
- For purely local state, useState is still right — don't put everything in a global store.
Zustand is unopinionated on purpose. That freedom is its strength and its risk: establish conventions early (one store per domain, actions colocated with state, selectors everywhere) so a growing app stays tidy.
Zustand gives you global state that's as simple as useState but shared, without providers or ceremony. Create a store, select the slice you need, keep server data elsewhere — and most of your state-management complexity disappears.