Skip to content
Learni
View all tutorials
React

How to Master Advanced Zustand in React in 2026

Lire en français

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

terminal
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 dev

This 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

src/store/taskStore.ts
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

src/App.tsx
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

src/store/userStore.ts
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

src/store/cartStore.ts
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

src/store/analyticsStore.ts
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

src/store/taskStore.test.ts
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 StateCreator generic 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 useShallow and noSSR for 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