Zustand State Management
Zustand minimal React state management — create store, selectors, middleware (persist, devtools, immer), slices pattern, and async actions
Zustand is a small, fast, and scalable state management library for React. It embraces simplicity over ceremony: no providers, no boilerplate, no context wrappers. Stores are plain JavaScript objects enhanced with reactivity. Components subscribe to slices of state via selectors, ensuring minimal re-renders. Zustand works outside React too, making it ideal for shared logic between UI and non-UI code. ## Key Points - **Minimal API surface** — one function (`create`) does everything - **No providers required** — stores are module-level singletons - **Selector-based subscriptions** — components only re-render when their selected state changes - **Middleware composable** — persist, devtools, immer, and custom middleware stack naturally - **TypeScript-first** — full type inference without extra generics in most cases 1. **One store per domain** — keep auth, cart, and UI state in separate stores rather than one mega-store. 2. **Always use selectors** — never destructure the entire store in a component. 3. **Colocate actions with state** — define mutations inside `create` so logic stays close to the data it modifies. 4. **Stack middleware deliberately** — `devtools(persist(immer(...)))` reads inside-out; immer runs first. 5. **Use `partialize` with persist** — avoid serializing functions or derived data to storage. 6. **Name devtools actions** — pass the third argument to `set()` for readable Redux DevTools logs. 7. **Derive computed values in selectors** — keep the store flat; compute totals and filters at read time. ## Quick Example ```bash npm install zustand # Optional middleware packages npm install immer # for immer middleware ```
skilldb get state-management-skills/Zustand State ManagementFull skill: 286 linesZustand State Management
Core Philosophy
Zustand is a small, fast, and scalable state management library for React. It embraces simplicity over ceremony: no providers, no boilerplate, no context wrappers. Stores are plain JavaScript objects enhanced with reactivity. Components subscribe to slices of state via selectors, ensuring minimal re-renders. Zustand works outside React too, making it ideal for shared logic between UI and non-UI code.
- Minimal API surface — one function (
create) does everything - No providers required — stores are module-level singletons
- Selector-based subscriptions — components only re-render when their selected state changes
- Middleware composable — persist, devtools, immer, and custom middleware stack naturally
- TypeScript-first — full type inference without extra generics in most cases
Setup
npm install zustand
# Optional middleware packages
npm install immer # for immer middleware
// store/useCounterStore.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// components/Counter.tsx
import { useCounterStore } from '../store/useCounterStore';
export function Counter() {
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+1</button>
</div>
);
}
Key Techniques
Selectors and Preventing Re-renders
// Bad — subscribes to entire store, re-renders on any change
const state = useCounterStore();
// Good — only re-renders when `count` changes
const count = useCounterStore((s) => s.count);
// Multiple values with shallow equality
import { useShallow } from 'zustand/react/shallow';
const { count, increment } = useCounterStore(
useShallow((s) => ({ count: s.count, increment: s.increment }))
);
Async Actions
interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
fetchTodos: () => Promise<void>;
addTodo: (title: string) => Promise<void>;
}
export const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
loading: false,
error: null,
fetchTodos: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/todos');
const todos = await res.json();
set({ todos, loading: false });
} catch (err) {
set({ error: (err as Error).message, loading: false });
}
},
addTodo: async (title: string) => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
headers: { 'Content-Type': 'application/json' },
});
const newTodo = await res.json();
set((state) => ({ todos: [...state.todos, newTodo] }));
},
}));
Persist Middleware
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsState {
theme: 'light' | 'dark';
locale: string;
setTheme: (theme: 'light' | 'dark') => void;
setLocale: (locale: string) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'light',
locale: 'en',
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: 'app-settings',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme, locale: state.locale }),
}
)
);
Devtools Middleware
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useAuthStore = create<AuthState>()(
devtools(
(set) => ({
user: null,
login: async (credentials: Credentials) => {
const user = await authApi.login(credentials);
set({ user }, false, 'auth/login');
},
logout: () => set({ user: null }, false, 'auth/logout'),
}),
{ name: 'AuthStore' }
)
);
Immer Middleware
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface CartState {
items: CartItem[];
addItem: (item: CartItem) => void;
updateQuantity: (id: string, qty: number) => void;
removeItem: (id: string) => void;
}
export const useCartStore = create<CartState>()(
immer((set) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
state.items.push(item);
}
}),
updateQuantity: (id, qty) =>
set((state) => {
const item = state.items.find((i) => i.id === id);
if (item) item.quantity = qty;
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id);
}),
}))
);
Slices Pattern for Large Stores
// store/slices/userSlice.ts
import { StateCreator } from 'zustand';
export interface UserSlice {
user: User | null;
setUser: (user: User | null) => void;
}
export const createUserSlice: StateCreator<
UserSlice & CartSlice,
[],
[],
UserSlice
> = (set) => ({
user: null,
setUser: (user) => set({ user }),
});
// store/slices/cartSlice.ts
export interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
}
export const createCartSlice: StateCreator<
UserSlice & CartSlice,
[],
[],
CartSlice
> = (set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
});
// store/useBoundStore.ts
import { create } from 'zustand';
export const useBoundStore = create<UserSlice & CartSlice>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}));
Using Store Outside React
// Access state imperatively (e.g., in API interceptors)
const token = useAuthStore.getState().token;
// Subscribe to changes outside React
const unsub = useAuthStore.subscribe(
(state) => state.user,
(user) => console.log('User changed:', user)
);
Best Practices
- One store per domain — keep auth, cart, and UI state in separate stores rather than one mega-store.
- Always use selectors — never destructure the entire store in a component.
- Colocate actions with state — define mutations inside
createso logic stays close to the data it modifies. - Stack middleware deliberately —
devtools(persist(immer(...)))reads inside-out; immer runs first. - Use
partializewith persist — avoid serializing functions or derived data to storage. - Name devtools actions — pass the third argument to
set()for readable Redux DevTools logs. - Derive computed values in selectors — keep the store flat; compute totals and filters at read time.
Anti-Patterns
- Subscribing to the whole store —
const state = useStore()triggers re-renders on every state change. - Storing derived data — duplicating data that can be computed from existing state leads to sync bugs.
- Mutating state directly — without the immer middleware, you must always return new objects from
set. - Giant monolithic stores — a single store with 30+ fields becomes hard to reason about; split into slices or separate stores.
- Using
getState()inside render — callinggetState()in a component body bypasses reactivity; use selectors instead. - Persisting functions or class instances — localStorage cannot serialize these; use
partializeto exclude them.
Install this skill directly: skilldb add state-management-skills
Related Skills
Jotai Atomic State Management
Jotai atomic state for React — primitive/derived/async atoms, Provider-less mode, atomWithStorage, atomWithQuery, and React Suspense integration
Legend State
Legend-State high-performance observable state for React — fine-grained reactivity, persistence plugins, computed observables, and sync engine
Nanostores
Nanostores tiny framework-agnostic state manager — atoms, computed stores, lifecycle events, and integrations with React, Vue, Svelte, and vanilla JS
Redux Toolkit
Redux Toolkit for scalable React state — createSlice, configureStore, RTK Query, createAsyncThunk, entity adapter, middleware, and TypeScript patterns
TanStack Query (React Query)
TanStack Query for server state — useQuery, useMutation, query invalidation, optimistic updates, infinite queries, prefetching, and SSR hydration
Valtio Proxy State Management
Valtio proxy-based state for React — mutable-style API with automatic tracking, snapshots, derived state, and nested object support