Introduction
Zustand is a React state management library that outperforms Redux in simplicity and performance, featuring a minimalist hook-based API. In 2026, it dominates scalable apps thanks to its native middlewares (persist, devtools, immer) and flawless TypeScript support. This expert tutorial guides you from creating a basic store to advanced implementations: async actions with thunks, optimized selectors to avoid unnecessary re-renders, local persistence, real-time debugging, and robust unit tests.
Why Zustand for pros? It reduces boilerplate by 90% compared to Redux, handles complex states like nested object trees without mutability pitfalls thanks to immer, and integrates seamlessly with React Server Components or TanStack Query. Imagine a user store with async authentication, persistent e-commerce cart, and dev performance metrics: all in < 100 lines. By the end, you'll master performance pitfalls and scale your React apps effortlessly. (128 words)
Prerequisites
- React 18+ and TypeScript 5.6+
- Vite or Next.js for the bundler
- Advanced React hooks knowledge (useEffect, useCallback)
- Node.js 20+ and npm/yarn/pnpm
- Testing tools: Vitest or Jest with @testing-library/react
Installing Zustand and Initializing the Project
npm create vite@latest mon-app-zustand -- --template react-ts
cd mon-app-zustand
npm install
npm install zustand @zustand-devtools/zustand-devtools immer
npm install -D @types/node
npm run devThis command creates a Vite React+TS project, installs Zustand with its essential middlewares (devtools for debugging, immer for safe mutability). Run npm run dev to verify. Avoid outdated versions: Zustand 5+ is required for 2026 APIs.
First Basic Store
Let's start with a simple store managing a counter and a task list. Zustand uses create to define the initial state and actions in a single function. No provider required: hooks work anywhere.
Basic Store with Sync Actions
import { create } from 'zustand';
import { devtools } from '@zustand-devtools/zustand-devtools';
type Task = { id: number; text: string; done: boolean };
type TaskStore = {
count: number;
tasks: Task[];
addTask: (text: string) => void;
toggleTask: (id: number) => void;
increment: () => void;
};
export const useTaskStore = create<TaskStore>()(
devtools((set, get) => ({
count: 0,
tasks: [],
addTask: (text) => {
const newTask: Task = { id: Date.now(), text, done: false };
set({ tasks: [...get().tasks, newTask], count: get().count + 1 });
},
toggleTask: (id) => {
set({
tasks: get().tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
),
});
},
increment: () => set((state) => ({ count: state.count + 1 })),
})),
);This store defines state with a counter and tasks, plus immutable actions via set and get. devtools enables the Redux DevTools panel for live state inspection. Use set((state) => ...) for derived updates: it's more performant than external closures.
Using the Store in a React Component
Integrate the store via useTaskStore. Selectors prevent re-renders: useTaskStore((state) => state.count) only re-renders if count changes.
Consumer Component with Selectors
import { useTaskStore } from './store/taskStore';
function App() {
const count = useTaskStore((state) => state.count);
const tasks = useTaskStore((state) => state.tasks);
const addTask = useTaskStore((state) => state.addTask);
const toggleTask = useTaskStore((state) => state.toggleTask);
const increment = useTaskStore((state) => state.increment);
return (
<div>
<h1>Compteur: {count}</h1>
<button onClick={increment}>+1</button>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleTask(task.id)}
/>
{task.text}
</li>
))}
</ul>
<input placeholder="Nouvelle tâche" onKeyDown={(e) => {
if (e.key === 'Enter') {
addTask((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = '';
}
}} />
</div>
);
}
export default App;
import React from 'react';Each selector is an independent hook: React optimizes re-renders via Zustand's internal shallow equality. Add useCallback if needed for child callbacks. This component is fully functional and scalable.
Async Actions with Thunks
For APIs, use thunks: actions that return promises. Zustand handles async natively without external middleware like Redux Toolkit.
Store with Async Thunks and Loading States
import { create } from 'zustand';
import { devtools } from '@zustand-devtools/zustand-devtools';
type User = { id: number; name: string; email: string };
type UserStore = {
users: User[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
createUser: (user: Omit<User, 'id'>) => Promise<void>;
};
const fakeApi = async (delay = 1000): Promise<User[]> => {
await new Promise((r) => setTimeout(r, delay));
return [
{ id: 1, name: 'Alice', email: 'alice@test.com' },
{ id: 2, name: 'Bob', email: 'bob@test.com' },
];
};
export const useUserStore = create<UserStore>()(
devtools((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const users = await fakeApi();
set({ users, loading: false });
} catch (e) {
set({ error: 'Erreur fetch', loading: false });
}
},
createUser: async (newUser) => {
set({ loading: true });
try {
await fakeApi(500);
const users = get().users;
const id = Math.max(...users.map(u => u.id), 0) + 1;
set({ users: [...users, { ...newUser, id }], loading: false });
} catch (e) {
set({ error: 'Erreur création', loading: false });
}
},
})),
);Async thunks use async/await directly in actions. Manage loading/error states globally for smooth UX. fakeApi simulates a real API: replace with fetch. Avoid race conditions by reading get() before async operations.
Advanced Middlewares: Persist and Immer
Add local persistence (localStorage) and safe mutability with immer. In production, persist syncs state to storage effortlessly.
Store with Persist, Immer, and Devtools
import { create } from 'zustand';
import { devtools, persist } from '@zustand-devtools/zustand-devtools';
import { immer } from 'zustand/middleware/immer';
type Item = { id: string; qty: number; price: number };
type CartStore = {
items: Record<string, Item>;
total: number;
addItem: (id: string, price: number) => void;
updateQty: (id: string, qty: number) => void;
removeItem: (id: string) => void;
};
export const useCartStore = create<CartStore>()(
devtools(
persist(
immer((set, get) => ({
items: {},
total: 0,
addItem: (id, price) => {
const current = get().items[id];
set((state) => {
state.items[id] = current ? { ...current, qty: current.qty + 1 } : { id, qty: 1, price };
state.total = Object.values(state.items).reduce((sum, item) => sum + item.qty * item.price, 0);
});
},
updateQty: (id, qty) => {
set((state) => {
if (qty === 0) {
delete state.items[id];
} else {
state.items[id] = { ...state.items[id], qty };
}
state.total = Object.values(state.items).reduce((sum, item) => sum + item.qty * item.price, 0);
});
},
removeItem: (id) => {
set((state) => {
delete state.items[id];
state.total = Object.values(state.items).reduce((sum, item) => sum + item.qty * item.price, 0);
});
},
})),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }),
},
),
{ name: 'CartStore' },
),
);Middleware stack: immer for direct mutations (state.items[id] = ...), persist for localStorage (partialize ignores total), devtools for tracing. Order matters: immer before persist. For SSR, use hydrate to avoid hydration mismatches.
Advanced Selectors and Performance
For large states, use shallow equality and derived selectors. Zustand uses shallow comparison by default, but createWithEqualityFn for custom needs.
Optimized Selectors with Shallow
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { devtools, persist } from '@zustand-devtools/zustand-devtools';
type Metrics = { users: number; revenue: number; events: string[] };
type AnalyticsStore = {
metrics: Metrics;
history: Metrics[];
updateMetrics: (newMetrics: Partial<Metrics>) => void;
};
export const useAnalyticsStore = create<AnalyticsStore>()(
devtools((set) => ({
metrics: { users: 0, revenue: 0, events: [] },
history: [],
updateMetrics: (newMetrics) => {
set((state) => {
const updated = { ...state.metrics, ...newMetrics };
return {
metrics: updated,
history: [...state.history.slice(-9), updated],
};
});
},
})),
);
// Usage optimisé dans composants:
// const { users, revenue } = useAnalyticsStore(
// (state) => ({ users: state.metrics.users, revenue: state.metrics.revenue }),
// shallow
// );
// Évite re-render si events change seul.Import shallow for shallow object comparison: ideal for derived tuples. history.slice(-9) limits size. In components, pass shallow as the second selector arg for zero unnecessary re-renders on large states.
Unit Testing Stores
Test stores in isolation with Vitest. Mock middlewares and assert state/actions.
Complete Unit Tests
import { renderHook, act } from '@testing-library/react';
import { useTaskStore } from './taskStore';
describe('TaskStore', () => {
it('should add and toggle tasks', () => {
const { result } = renderHook(() => useTaskStore());
act(() => {
result.current.addTask('Test task');
});
expect(result.current.tasks).toHaveLength(1);
expect(result.current.tasks[0].text).toBe('Test task');
act(() => {
result.current.toggleTask(result.current.tasks[0].id);
});
expect(result.current.tasks[0].done).toBe(true);
expect(result.current.count).toBe(1);
});
it('should increment count', () => {
const { result } = renderHook(() => useTaskStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});renderHook + act simulates React without the DOM. Test the store API purely. For async/thunks, use waitFor. Aim for 100% coverage: actions, edge cases (empty lists, errors).
Best Practices
- Always use strict typing: leverage the
StateCreatorgeneric for perfect IntelliSense. - Separate concerns: one store per domain (user, cart, UI).
- Minimal middleware stack: immer + persist + devtools only; disable persist in tests.
- Selectors first: derive computed state instead of recalculating in components.
- SSR safe: use
useShallowandnoSSRfor middlewares in Next.js.
Common Errors to Avoid
- Mutating state directly without immer: crashes in production (useState-like error).
- Forgetting shallow on objects: infinite re-renders on lists.
- Persisting everything: use partialize to avoid memory leaks (don't store transients like loading).
- Async race conditions: always
get()before await, not captured closures.
Next Steps
- Official docs: Zustand GitHub
- Advanced middleware: Zustand Router
- TanStack integration: for queries + mutations
- Check out our advanced React trainings at Learni to scale your stateful apps.