Introduction
In 2026, Zustand remains React developers' top pick for a minimalist, ultra-performant state manager, outshining Redux in simplicity while delivering expert-level scalability. Unlike verbose alternatives, it leverages a single useStore hook and fine-grained selectors to skip unnecessary re-renders. This advanced tutorial dives into key configurations: local persistence, safe mutability with Immer, debugging via devtools middleware, async actions with thunks, and multi-store setups. Perfect for complex enterprise apps like analytics dashboards or reactive e-commerce. You'll structure modular stores, slash re-renders by up to 90%, and manage shared global state without boilerplate. By the end, your React apps will be scaled for production. (142 words)
Prerequisites
- React 18+ and TypeScript 5+
- Advanced React hooks knowledge (useEffect, useMemo, useCallback)
- Vite or Next.js for development
- Node.js 20+ and npm/yarn/pnpm
Installing Zustand and Middlewares
npm create vite@latest mon-app-zustand -- --template react-ts
cd mon-app-zustand
npm install
npm install zustand
npm install @zustand-devtools/devtools zustand/middleware
npm install immer
npm run devThis command sets up a Vite React-TypeScript project, installs core Zustand, devtools middleware for real-time debugging, and Immer for immutable updates. Run npm run dev to test right away. Skip pnpm if you run into lockfile issues.
Basic Store with Selectors
Start with the basics: a simple counter store with actions and selectors. Selectors act like a 'focused projector' on state slices, preventing full re-renders—components only update when their data changes.
Creating a Basic Counter Store
import { create } from 'zustand';
type 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 }),
}));This store defines a count state and three immutable actions via set. Use functional updates (state) => ({ ... }) for safe derived calculations. Pitfall: Never do set(state => state) as it mutates directly.
Using the Store in a React Component
import { useCounterStore } from './stores/counterStore';
function App() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div style={{ padding: '20px', fontFamily: 'Arial' }}>
<h1>Compteur: {count}</h1>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
);
}
export default App;Destructure selectively to minimize re-renders. Each button triggers a targeted action. Test it: only the h1 updates. Pro tip: No Provider needed—global store ready to go.
Derived Selectors for Optimization
For pro-level apps, derived selectors compute values on the fly without extra storage. Think of it as a virtual getter that 'counts sheep' without recounting every time.
Store with Derived Selectors
import { create } from 'zustand';
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
doubleCount: number;
};
export const useCounterStore = create<CounterState>((set, get) => ({
count: 0,
doubleCount: 0,
increment: () => {
const newCount = get().count + 1;
set({ count: newCount, doubleCount: newCount * 2 });
},
decrement: () => {
const newCount = get().count - 1;
set({ count: newCount, doubleCount: newCount * 2 });
},
}));Use get() to read current state in actions. doubleCount updates atomically with the base value. Pitfall: Always sync derivatives for consistency.
Persistence with Middleware
Survive page refreshes by adding persist: stores to localStorage by default, customizable for IndexedDB or sessionStorage.
Persistent Store with Custom Key
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type PersistState = {
count: number;
increment: () => void;
};
export const usePersistStore = create<PersistState>()(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'counter-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ count: state.count }),
}
)
);persist middleware serializes state. partialize saves only count for security. Customize storage with createJSONStorage. Refresh the page: state persists.
Devtools for Expert Debugging
Zustand devtools hook into Redux DevTools: time-travel debugging, action/state inspection.
Integrated Devtools Middleware
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type DevtoolsState = {
todos: string[];
addTodo: (todo: string) => void;
removeTodo: (index: number) => void;
};
export const useDevtoolsStore = create<DevtoolsState>()(
devtools(
(set) => ({
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
removeTodo: (index) => set((state) => ({ todos: state.todos.filter((_, i) => i !== index) })),
}),
{ name: 'todos-devtools' }
)
);devtools wraps the store with a name for easy ID. Open Redux DevTools to trace actions. Pitfall: Disable in production with if (process.env.NODE_ENV === 'development').
Safe Mutability with Immer
Immer lets you 'mutate' a draft state safely, perfect for complex nested objects without manual immutability.
Store with Immer for Nested State
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
type UserState = {
users: { id: number; name: string; tasks: string[] }[];
addUser: (name: string) => void;
addTask: (userId: number, task: string) => void;
};
export const useImmerStore = create<UserState>()(
immer((set) => ({
users: [],
addUser: (name) => {
set((state) => {
state.users.push({ id: Date.now(), name, tasks: [] });
});
},
addTask: (userId, task) => {
set((state) => {
const user = state.users.find((u) => u.id === userId);
if (user) user.tasks.push(task);
});
},
}))
);immer enables draft mutations like state.users.push(). It produces a final immutable state. Ideal for deep state graphs without verbose spreads.
Async Actions with Thunks
Handle APIs and fetch with thunks: async actions that manage loading, error, and success states.
Async Store with Fetch API
import { create } from 'zustand';
type AsyncState = {
data: string[];
loading: boolean;
error: string | null;
fetchData: () => Promise<void>;
};
export const useAsyncStore = create<AsyncState>((set, get) => ({
data: [],
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
const todos = await res.json();
set({
data: todos.map((todo: any) => todo.title),
loading: false
});
} catch (err) {
set({ loading: false, error: 'Erreur fetch' });
}
},
}));Async actions use await and multiple set calls for intermediate states. Use get() for advanced cancel tokens. Handle errors globally for solid UX.
Multi-Store Architecture (Slices)
Scale with slices: combine modular stores without tight coupling.
Multi-Stores with Combine
import { create } from 'zustand';
import { combine } from 'zustand/middleware';
type CounterSlice = {
count: number;
increment: () => void;
};
type TodoSlice = {
todos: string[];
addTodo: (todo: string) => void;
};
export const useCombinedStore = create(
combine(
{ count: 0 as number, todos: [] as string[] },
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
})
)
);combine merges logical slices. Simplifies refactoring: extract slices to separate files. Pitfall: Avoid circular dependencies between slices.
Best Practices
- Separate concerns: One slice per feature (users, cart, UI).
- Type everything: Use
StateCreatorfor chained middlewares. - Optimize selectors: Shallow equality with Zustand's
shallowfor arrays/objects. - Lazy hydration: For SSR persistence in Next.js, hydrate on mount.
- Middleware chaining:
devtools(persist(immer(store)))for full stack.
Common Pitfalls to Avoid
- Direct mutations without Immer: Leads to inconsistent states and infinite re-renders.
- Unmemoized selectors: Broad destructuring tanks perf (use
store((state) => state.slice)). - Sensitive persistence: Never store tokens/secrets; use
partialize({}). - Async without loading: Skip loading/error states and hurt UX (always try/catch).
Next Steps
Dive deeper with the official Zustand docs. Pair with Jotai for atomic granularity or Valtio for proxies. Explore our advanced React trainings at Learni for production state management. GitHub examples: search 'zustand-vanilla-extract' for styling integration.