Cours Next.js · Module 9: Intégrations et fonctions avancées

Next.js avec Zustand - Gestion d'état moderne

21 min de lecture
Dans cette leçon11

Le panier de la boutique de la Métropole Quantum doit être visible dans l'en-tête, dans le tiroir des produits et sur la page de paiement. Le faire passer en props à travers cinq niveaux de composants se transforme vite en un enchevêtrement de câbles. Les Server Components récupèrent les données du serveur, mais l'état purement client, comme un panier, un thème ou des notifications, a besoin d'un magasin partagé. Neo-Bot, le gardien de l'état de la Métropole, recommande pour cela Zustand - une bibliothèque de gestion d'état minimaliste mais puissante.

Qu'est-ce que Zustand ?

Zustand est une bibliothèque moderne de gestion d'état qui offre :

  • Minimalist API - une API simple et intuitive
  • TypeScript-first - un support complet du typage
  • No boilerplate - pas de code répétitif
  • SSR-friendly - la prise en charge du Server-Side Rendering
  • DevTools support - l'intégration avec Redux DevTools
  • Middleware ecosystem - un riche écosystème de middlewares

Avantages de Zustand par rapport à Redux

  • Moins de code - pas d'actions, de reducers ni de dispatchers
  • Meilleur TypeScript - un support naturel des types
  • Modular design - découpage facile des stores
  • Performance - optimisation automatique des re-rendus

La différence la plus importante : Zustand n'exige ni provider autour de l'application ni reducers. Un store est un simple hook que tu importes là où tu en as besoin.

Installation et configuration de base

Installation

La configuration suit toujours le même ordre : installer le paquet, créer le store avec create(), définir l'état et les actions, puis utiliser le hook dans un composant. On commence par l'installation :

1# Installe Zustand
2npm install zustand
3
4# Pour les outils de développement (optionnel)
5npm install @redux-devtools/extension

Le second paquet est optionnel. Pour inspecter l'état, l'extension de navigateur Redux DevTools et le middleware devtools, que tu verras bientôt, suffisent.

Store de base

On décrit d'abord la forme du store avec l'interface CounterState : une valeur count et trois actions. La fonction create renvoie un hook, et set fusionne l'objet passé avec l'état :

1// stores/counterStore.ts
2import { create } from 'zustand';
3
4interface CounterState {
5  count: number;
6  increment: () => void;
7  decrement: () => void;
8  reset: () => void;
9}
10
11export const useCounterStore = create<CounterState>()((set) => ({
12  count: 0,
13  increment: () => set((state) => ({ count: state.count + 1 })),
14  decrement: () => set((state) => ({ count: state.count - 1 })),
15  reset: () => set({ count: 0 }),
16}));

Remarque les doubles parenthèses create<CounterState>()(...) : c'est la forme recommandée en TypeScript, qui permet d'inférer correctement les types. Les actions sont de simples fonctions dans le même objet que les données, sans aucun dispatch.

Utilisation dans un composant

Le hook useCounterStore ne fonctionne que dans les composants client, c'est pourquoi le fichier commence par 'use client' :

1// components/Counter.tsx
2'use client';
3
4import { useCounterStore } from '@/stores/counterStore';
5
6export default function Counter() {
7  const { count, increment, decrement, reset } = useCounterStore();
8
9  return (
10    <div className="bg-white p-6 rounded-lg shadow">
11      <h2 className="text-2xl font-bold mb-4">Compteur : {count}</h2>
12
13      <div className="space-x-4">
14        <button
15          onClick={increment}
16          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
17        >
18          +1
19        </button>
20
21        <button
22          onClick={decrement}
23          className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
24        >
25          -1
26        </button>
27
28        <button
29          onClick={reset}
30          className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
31        >
32          Reset
33        </button>
34      </div>
35    </div>
36  );
37}

Le compteur fonctionne sans provider dans l'arbre. Cette écriture lit cependant tout le store, donc le composant se re-rend à chaque changement, et dans la section sur la performance tu verras comment corriger cela avec des selectors.

Patterns avancés

Store utilisateur avec authentification

Un vrai store garde aussi l'état de chargement et les erreurs. Le middleware persist enregistre une partie choisie de l'état dans localStorage, et get permet de lire l'état à l'intérieur d'une action :

1// stores/userStore.ts
2import { create } from 'zustand';
3import { persist, createJSONStorage } from 'zustand/middleware';
4
5interface User {
6  id: string;
7  name: string;
8  email: string;
9  avatar?: string;
10  role: 'user' | 'admin';
11}
12
13interface UserState {
14  user: User | null;
15  isLoading: boolean;
16  error: string | null;
17  login: (email: string, password: string) => Promise<void>;
18  logout: () => void;
19  updateProfile: (data: Partial<User>) => Promise<void>;
20  setUser: (user: User) => void;
21  clearError: () => void;
22}
23
24export const useUserStore = create<UserState>()(
25  persist(
26    (set, get) => ({
27      user: null,
28      isLoading: false,
29      error: null,
30
31      login: async (email: string, password: string) => {
32        set({ isLoading: true, error: null });
33
34        try {
35          const response = await fetch('/api/auth/login', {
36            method: 'POST',
37            headers: { 'Content-Type': 'application/json' },
38            body: JSON.stringify({ email, password }),
39          });
40
41          if (!response.ok) {
42            throw new Error('Identifiants de connexion invalides');
43          }
44
45          const userData = await response.json();
46          set({ user: userData.user, isLoading: false });
47        } catch (error) {
48          set({
49            error: error instanceof Error ? error.message : 'Une erreur est survenue',
50            isLoading: false
51          });
52        }
53      },
54
55      logout: () => {
56        set({ user: null, error: null });
57        // Appelle l'API de déconnexion
58        fetch('/api/auth/logout', { method: 'POST' });
59      },
60
61      updateProfile: async (data: Partial<User>) => {
62        const { user } = get();
63        if (!user) return;
64
65        set({ isLoading: true });
66
67        try {
68          const response = await fetch('/api/user/profile', {
69            method: 'PATCH',
70            headers: { 'Content-Type': 'application/json' },
71            body: JSON.stringify(data),
72          });
73
74          if (!response.ok) {
75            throw new Error('Impossible de mettre à jour le profil');
76          }
77
78          const updatedUser = await response.json();
79          set({ user: updatedUser, isLoading: false });
80        } catch (error) {
81          set({
82            error: error instanceof Error ? error.message : 'Une erreur est survenue',
83            isLoading: false
84          });
85        }
86      },
87
88      setUser: (user: User) => set({ user }),
89      clearError: () => set({ error: null }),
90    }),
91    {
92      name: 'user-storage',
93      storage: createJSONStorage(() => localStorage),
94      partialize: (state) => ({ user: state.user }), // Ne persiste que les données de l'utilisateur
95    }
96  )
97);

Les actions asynchrones sont de simples fonctions async, donc Zustand n'a pas besoin de middleware de type thunk. L'option partialize n'enregistre que user, sans les indicateurs de chargement et d'erreur. Attention au SSR : le serveur ne voit pas localStorage, donc l'état restauré côté client peut différer du HTML du serveur.

Store du panier

Le panier montre comment modifier des tableaux sans les muter. Le type Omit<CartItem, 'quantity'> désigne un produit sans le champ quantité, car c'est le store qui fixe la quantité :

1// stores/cartStore.ts
2import { create } from 'zustand';
3import { persist } from 'zustand/middleware';
4
5interface CartItem {
6  id: string;
7  name: string;
8  price: number;
9  quantity: number;
10  image?: string;
11  variant?: {
12    size?: string;
13    color?: string;
14  };
15}
16
17interface CartState {
18  items: CartItem[];
19  isOpen: boolean;
20  addItem: (item: Omit<CartItem, 'quantity'>) => void;
21  removeItem: (id: string) => void;
22  updateQuantity: (id: string, quantity: number) => void;
23  clearCart: () => void;
24  toggleCart: () => void;
25  getTotalItems: () => number;
26  getTotalPrice: () => number;
27}
28
29export const useCartStore = create<CartState>()(
30  persist(
31    (set, get) => ({
32      items: [],
33      isOpen: false,
34
35      addItem: (newItem) => {
36        const { items } = get();
37        const existingItem = items.find(item =>
38          item.id === newItem.id &&
39          JSON.stringify(item.variant) === JSON.stringify(newItem.variant)
40        );
41
42        if (existingItem) {
43          set({
44            items: items.map(item =>
45              item === existingItem
46                ? { ...item, quantity: item.quantity + 1 }
47                : item
48            ),
49          });
50        } else {
51          set({
52            items: [...items, { ...newItem, quantity: 1 }],
53          });
54        }
55      },
56
57      removeItem: (id) => {
58        set({
59          items: get().items.filter(item => item.id !== id),
60        });
61      },
62
63      updateQuantity: (id, quantity) => {
64        if (quantity <= 0) {
65          get().removeItem(id);
66          return;
67        }
68
69        set({
70          items: get().items.map(item =>
71            item.id === id ? { ...item, quantity } : item
72          ),
73        });
74      },
75
76      clearCart: () => set({ items: [] }),
77
78      toggleCart: () => set({ isOpen: !get().isOpen }),
79
80      getTotalItems: () => {
81        return get().items.reduce((total, item) => total + item.quantity, 0);
82      },
83
84      getTotalPrice: () => {
85        return get().items.reduce(
86          (total, item) => total + item.price * item.quantity,
87          0
88        );
89      },
90    }),
91    {
92      name: 'cart-storage',
93    }
94  )
95);

addItem augmente la quantité si le produit dans la même variante est déjà dans le panier, et sinon ajoute une nouvelle ligne. Le tableau d'origine ne change jamais : map et filter en créent toujours un nouveau, et c'est ainsi que React détecte le changement.

Middleware et DevTools

Intégration de Redux DevTools

Les middlewares intégrés de Zustand comprennent notamment devtools, persist, immer, subscribeWithSelector, combine et redux. devtools affiche chaque changement d'état dans l'extension Redux DevTools :

1// stores/appStore.ts
2import { create } from 'zustand';
3import { devtools } from 'zustand/middleware';
4
5interface AppState {
6  theme: 'light' | 'dark';
7  sidebarOpen: boolean;
8  notifications: Array<{
9    id: string;
10    type: 'info' | 'success' | 'warning' | 'error';
11    message: string;
12    timestamp: number;
13  }>;
14  toggleTheme: () => void;
15  toggleSidebar: () => void;
16  addNotification: (notification: Omit<AppState['notifications'][0], 'id' | 'timestamp'>) => void;
17  removeNotification: (id: string) => void;
18}
19
20export const useAppStore = create<AppState>()(
21  devtools(
22    (set, get) => ({
23      theme: 'light',
24      sidebarOpen: false,
25      notifications: [],
26
27      toggleTheme: () => {
28        const newTheme = get().theme === 'light' ? 'dark' : 'light';
29        set({ theme: newTheme });
30
31        // Met à jour la classe du document
32        if (typeof window !== 'undefined') {
33          document.documentElement.classList.toggle('dark', newTheme === 'dark');
34        }
35      },
36
37      toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
38
39      addNotification: (notification) => {
40        const id = Math.random().toString(36).substring(2);
41        const timestamp = Date.now();
42
43        set({
44          notifications: [
45            ...get().notifications,
46            { ...notification, id, timestamp }
47          ]
48        });
49
50        // Suppression automatique après 5 secondes
51        setTimeout(() => {
52          get().removeNotification(id);
53        }, 5000);
54      },
55
56      removeNotification: (id) => {
57        set({
58          notifications: get().notifications.filter(n => n.id !== id)
59        });
60      },
61    }),
62    {
63      name: 'app-store',
64    }
65  )
66);

Le thème, le panneau latéral et les notifications vivent dans un seul store d'application. Une notification disparaît toute seule après 5 secondes, et typeof window empêche le code de s'exécuter sur le serveur.

Middleware Immer pour des mises à jour immuables

Le middleware immer te permet d'écrire les changements comme si tu mutais l'état. Il nécessite un paquet séparé :

1npm install immer

Désormais, state.todos.push(...) dans set est sans danger, car Immer crée une nouvelle copie en coulisses :

1// stores/todosStore.ts
2import { create } from 'zustand';
3import { immer } from 'zustand/middleware/immer';
4
5interface Todo {
6  id: string;
7  text: string;
8  completed: boolean;
9  priority: 'low' | 'medium' | 'high';
10  dueDate?: Date;
11}
12
13interface TodosState {
14  todos: Todo[];
15  filter: 'all' | 'active' | 'completed';
16  addTodo: (text: string, priority?: Todo['priority']) => void;
17  toggleTodo: (id: string) => void;
18  deleteTodo: (id: string) => void;
19  editTodo: (id: string, text: string) => void;
20  setFilter: (filter: TodosState['filter']) => void;
21  clearCompleted: () => void;
22}
23
24export const useTodosStore = create<TodosState>()(
25  immer((set) => ({
26    todos: [],
27    filter: 'all',
28
29    addTodo: (text, priority = 'medium') =>
30      set((state) => {
31        state.todos.push({
32          id: Math.random().toString(36).substring(2),
33          text,
34          completed: false,
35          priority,
36        });
37      }),
38
39    toggleTodo: (id) =>
40      set((state) => {
41        const todo = state.todos.find((t) => t.id === id);
42        if (todo) {
43          todo.completed = !todo.completed;
44        }
45      }),
46
47    deleteTodo: (id) =>
48      set((state) => {
49        state.todos = state.todos.filter((t) => t.id !== id);
50      }),
51
52    editTodo: (id, text) =>
53      set((state) => {
54        const todo = state.todos.find((t) => t.id === id);
55        if (todo) {
56          todo.text = text;
57        }
58      }),
59
60    setFilter: (filter) =>
61      set((state) => {
62        state.filter = filter;
63      }),
64
65    clearCompleted: () =>
66      set((state) => {
67        state.todos = state.todos.filter((t) => !t.completed);
68      }),
69  }))
70);

Compare toggleTodo avec une version sans Immer : au lieu de mapper tout le tableau, tu modifies un seul champ. De l'extérieur rien ne change, les composants reçoivent toujours un état immuable.

SSR et hydratation

Store provider pour le SSR

Un store créé au niveau du module est partagé par toutes les requêtes sur le serveur, donc les données d'un utilisateur pourraient fuiter vers un autre. La documentation de Zustand recommande dans Next.js un store distinct par requête, transmis via le React Context :

1// providers/StoreProvider.tsx
2'use client';
3
4import { type ReactNode, createContext, useRef, useContext } from 'react';
5import { useStore } from 'zustand';
6import { type UserStore, createUserStore, initUserStore } from '@/stores/userStore';
7
8export type UserStoreApi = ReturnType<typeof createUserStore>;
9
10export const UserStoreContext = createContext<UserStoreApi | undefined>(undefined);
11
12export interface UserStoreProviderProps {
13  children: ReactNode;
14  initialState?: Parameters<typeof initUserStore>[0];
15}
16
17export const UserStoreProvider = ({ children, initialState }: UserStoreProviderProps) => {
18  const storeRef = useRef<UserStoreApi | null>(null);
19
20  if (!storeRef.current) {
21    storeRef.current = createUserStore(initUserStore(initialState));
22  }
23
24  return (
25    <UserStoreContext.Provider value={storeRef.current}>
26      {children}
27    </UserStoreContext.Provider>
28  );
29};
30
31export const useUserStore = <T,>(selector: (store: UserStore) => T): T => {
32  const userStoreContext = useContext(UserStoreContext);
33
34  if (!userStoreContext) {
35    throw new Error('useUserStore must be used within UserStoreProvider');
36  }
37
38  return useStore(userStoreContext, selector);
39};

useRef garantit que le store n'est créé qu'une fois par instance du provider. Dans React 19, useRef exige une valeur initiale, d'où null. Le hook useStore du paquet zustand lit la partie choisie du store depuis le contexte.

Définition du store mise à jour

On crée maintenant le store lui-même avec createStore de zustand/vanilla, qui renvoie un objet store au lieu d'un hook :

1// stores/userStore.ts (updated for SSR)
2import { createStore } from 'zustand/vanilla';
3
4export interface UserStore {
5  user: User | null;
6  isLoading: boolean;
7  login: (email: string, password: string) => Promise<void>;
8  logout: () => void;
9}
10
11export const initUserStore = (initialState?: Partial<UserStore>): UserStore => {
12  return {
13    user: null,
14    isLoading: false,
15    login: async (email: string, password: string) => {
16      // Implémentation
17    },
18    logout: () => {
19      // Implémentation
20    },
21    ...initialState,
22  };
23};
24
25export const createUserStore = (initialState: UserStore) => {
26  return createStore<UserStore>()(() => initialState);
27};

initUserStore construit l'état initial et createUserStore crée le store. La logique des actions n'a pas changé, seul l'endroit où le store est créé change.

Composants avec Zustand

Composant de bascule du thème

Le bouton de thème lit l'état et une action depuis le store de l'application :

1// components/ThemeToggle.tsx
2'use client';
3
4import { useAppStore } from '@/stores/appStore';
5import { Moon, Sun } from 'lucide-react';
6
7export default function ThemeToggle() {
8  const { theme, toggleTheme } = useAppStore();
9
10  return (
11    <button
12      onClick={toggleTheme}
13      className="p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
14      aria-label="Toggle theme"
15    >
16      {theme === 'light' ? (
17        <Moon className="w-5 h-5" />
18      ) : (
19        <Sun className="w-5 h-5" />
20      )}
21    </button>
22  );
23}

Le composant ne sait rien de document ni de la classe dark, c'est le store qui garde toute la logique.

Tiroir du panier

Le tiroir du panier est un composant plus grand qui utilise toutes les actions du panier :

1// components/CartDrawer.tsx
2'use client';
3
4import { useCartStore } from '@/stores/cartStore';
5import { X, Plus, Minus, Trash2 } from 'lucide-react';
6
7export default function CartDrawer() {
8  const {
9    items,
10    isOpen,
11    toggleCart,
12    updateQuantity,
13    removeItem,
14    getTotalPrice,
15    getTotalItems,
16  } = useCartStore();
17
18  if (!isOpen) return null;
19
20  return (
21    <div className="fixed inset-0 z-50">
22      {/* Overlay */}
23      <div
24        className="absolute inset-0 bg-black/50"
25        onClick={toggleCart}
26      />
27
28      {/* Drawer */}
29      <div className="absolute right-0 top-0 h-full w-96 bg-white shadow-xl">
30        <div className="flex items-center justify-between p-4 border-b">
31          <h2 className="text-lg font-semibold">
32            Panier ({getTotalItems()})
33          </h2>
34          <button
35            onClick={toggleCart}
36            className="p-2 hover:bg-gray-100 rounded"
37          >
38            <X className="w-5 h-5" />
39          </button>
40        </div>
41
42        <div className="flex-1 overflow-y-auto p-4">
43          {items.length === 0 ? (
44            <p className="text-gray-500 text-center mt-8">
45              Le panier est vide
46            </p>
47          ) : (
48            <div className="space-y-4">
49              {items.map((item) => (
50                <div key={item.id} className="flex items-center space-x-4 p-4 border rounded">
51                  {item.image && (
52                    <img
53                      src={item.image}
54                      alt={item.name}
55                      className="w-16 h-16 object-cover rounded"
56                    />
57                  )}
58
59                  <div className="flex-1">
60                    <h3 className="font-medium">{item.name}</h3>
61                    <p className="text-gray-600">{item.price} €</p>
62
63                    {item.variant && (
64                      <p className="text-sm text-gray-500">
65                        {item.variant.size && `Taille : ${item.variant.size}`}
66                        {item.variant.color && ` Couleur : ${item.variant.color}`}
67                      </p>
68                    )}
69                  </div>
70
71                  <div className="flex items-center space-x-2">
72                    <button
73                      onClick={() => updateQuantity(item.id, item.quantity - 1)}
74                      className="p-1 hover:bg-gray-100 rounded"
75                    >
76                      <Minus className="w-4 h-4" />
77                    </button>
78
79                    <span className="w-8 text-center">{item.quantity}</span>
80
81                    <button
82                      onClick={() => updateQuantity(item.id, item.quantity + 1)}
83                      className="p-1 hover:bg-gray-100 rounded"
84                    >
85                      <Plus className="w-4 h-4" />
86                    </button>
87
88                    <button
89                      onClick={() => removeItem(item.id)}
90                      className="p-1 hover:bg-red-100 text-red-600 rounded"
91                    >
92                      <Trash2 className="w-4 h-4" />
93                    </button>
94                  </div>
95                </div>
96              ))}
97            </div>
98          )}
99        </div>
100
101        {items.length > 0 && (
102          <div className="border-t p-4">
103            <div className="flex justify-between mb-4">
104              <span className="font-semibold">Total :</span>
105              <span className="font-semibold">{getTotalPrice()} €</span>
106            </div>
107
108            <button className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors">
109              Passer à la caisse
110            </button>
111          </div>
112        )}
113      </div>
114    </div>
115  );
116}

Les boutons plus et moins appellent updateQuantity, et descendre à zéro supprime la ligne, car c'est ainsi que nous avons défini l'action dans le store. Le composant ne calcule rien lui-même, les totaux viennent de getTotalItems et getTotalPrice.

Composant de notifications

La liste des notifications rend un composant NotificationItem distinct pour chaque entrée :

1// components/Notifications.tsx
2'use client';
3
4import { useEffect } from 'react';
5import { useAppStore } from '@/stores/appStore';
6import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
7
8export default function Notifications() {
9  const { notifications, removeNotification } = useAppStore();
10
11  return (
12    <div className="fixed top-4 right-4 z-50 space-y-2">
13      {notifications.map((notification) => (
14        <NotificationItem
15          key={notification.id}
16          notification={notification}
17          onRemove={() => removeNotification(notification.id)}
18        />
19      ))}
20    </div>
21  );
22}
23
24interface NotificationItemProps {
25  notification: {
26    id: string;
27    type: 'info' | 'success' | 'warning' | 'error';
28    message: string;
29    timestamp: number;
30  };
31  onRemove: () => void;
32}
33
34function NotificationItem({ notification, onRemove }: NotificationItemProps) {
35  const { type, message } = notification;
36
37  const icons = {
38    info: Info,
39    success: CheckCircle,
40    warning: AlertTriangle,
41    error: AlertCircle,
42  };
43
44  const colors = {
45    info: 'bg-blue-50 border-blue-200 text-blue-800',
46    success: 'bg-green-50 border-green-200 text-green-800',
47    warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
48    error: 'bg-red-50 border-red-200 text-red-800',
49  };
50
51  const Icon = icons[type];
52
53  useEffect(() => {
54    const timer = setTimeout(onRemove, 5000);
55    return () => clearTimeout(timer);
56  }, [onRemove]);
57
58  return (
59    <div className={`flex items-center p-4 border rounded-lg shadow-lg ${colors[type]} min-w-80 max-w-md`}>
60      <Icon className="w-5 h-5 mr-3 shrink-0" />
61
62      <div className="flex-1">
63        <p className="text-sm font-medium">{message}</p>
64      </div>
65
66      <button
67        onClick={onRemove}
68        className="ml-3 shrink-0 p-1 hover:bg-white hover:bg-white/20 rounded"
69      >
70        <X className="w-4 h-4" />
71      </button>
72    </div>
73  );
74}

Les icônes et les couleurs sont de simples objets choisis selon le type de notification. Le store supprime déjà une notification après 5 secondes, donc le minuteur du composant est une sécurité supplémentaire.

Intégration d'API

Client API avec Zustand

Un client HTTP peut lire l'état en dehors de React via getState(), sans hook :

1// lib/api-client.ts
2import { useUserStore } from '@/stores/userStore';
3
4class ApiClient {
5  private baseURL: string;
6
7  constructor(baseURL: string) {
8    this.baseURL = baseURL;
9  }
10
11  private async request<T>(
12    endpoint: string,
13    options: RequestInit = {}
14  ): Promise<T> {
15    const { user } = useUserStore.getState();
16
17    const config: RequestInit = {
18      headers: {
19        'Content-Type': 'application/json',
20        ...(user && { Authorization: `Bearer ${user.token}` }),
21        ...options.headers,
22      },
23      ...options,
24    };
25
26    const response = await fetch(`${this.baseURL}${endpoint}`, config);
27
28    if (!response.ok) {
29      throw new Error(`API Error: ${response.status}`);
30    }
31
32    return response.json();
33  }
34
35  get<T>(endpoint: string): Promise<T> {
36    return this.request<T>(endpoint);
37  }
38
39  post<T>(endpoint: string, data: any): Promise<T> {
40    return this.request<T>(endpoint, {
41      method: 'POST',
42      body: JSON.stringify(data),
43    });
44  }
45
46  put<T>(endpoint: string, data: any): Promise<T> {
47    return this.request<T>(endpoint, {
48      method: 'PUT',
49      body: JSON.stringify(data),
50    });
51  }
52
53  delete<T>(endpoint: string): Promise<T> {
54    return this.request<T>(endpoint, {
55      method: 'DELETE',
56    });
57  }
58}
59
60export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL || '');

Chaque requête reçoit un en-tête d'autorisation quand l'utilisateur est connecté. Dans un vrai projet, le type User a alors besoin d'un champ token.

Store des produits avec une API

Le store des produits combine la récupération des données, la recherche et le filtrage :

1// stores/productsStore.ts
2import { create } from 'zustand';
3import { apiClient } from '@/lib/api-client';
4
5interface Product {
6  id: string;
7  name: string;
8  price: number;
9  description: string;
10  images: string[];
11  category: string;
12  inStock: boolean;
13}
14
15interface ProductsState {
16  products: Product[];
17  loading: boolean;
18  error: string | null;
19  searchQuery: string;
20  selectedCategory: string | null;
21  fetchProducts: () => Promise<void>;
22  searchProducts: (query: string) => void;
23  filterByCategory: (category: string | null) => void;
24  getFilteredProducts: () => Product[];
25}
26
27export const useProductsStore = create<ProductsState>()((set, get) => ({
28  products: [],
29  loading: false,
30  error: null,
31  searchQuery: '',
32  selectedCategory: null,
33
34  fetchProducts: async () => {
35    set({ loading: true, error: null });
36
37    try {
38      const products = await apiClient.get<Product[]>('/products');
39      set({ products, loading: false });
40    } catch (error) {
41      set({
42        error: error instanceof Error ? error.message : 'Failed to fetch products',
43        loading: false,
44      });
45    }
46  },
47
48  searchProducts: (query: string) => {
49    set({ searchQuery: query });
50  },
51
52  filterByCategory: (category: string | null) => {
53    set({ selectedCategory: category });
54  },
55
56  getFilteredProducts: () => {
57    const { products, searchQuery, selectedCategory } = get();
58
59    return products.filter((product) => {
60      const matchesSearch = searchQuery === '' ||
61        product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
62        product.description.toLowerCase().includes(searchQuery.toLowerCase());
63
64      const matchesCategory = selectedCategory === null ||
65        product.category === selectedCategory;
66
67      return matchesSearch && matchesCategory;
68    });
69  },
70}));

getFilteredProducts calcule le résultat à partir de l'état actuel au lieu de garder une seconde copie de la liste. Dans Next.js, il vaut pourtant mieux récupérer les données initiales dans un Server Component et ne garder dans Zustand que l'état de l'interface, comme la phrase de recherche.

Optimisation des performances

Selectors pour l'optimisation

Un selector ne prend que la partie nécessaire du store, donc le composant ne se re-rend que lorsque cette partie change. Quand un selector renvoie un nouvel objet, enveloppe-le dans useShallow :

1// hooks/useOptimizedSelectors.ts
2import { useShallow } from 'zustand/react/shallow';
3import { useCartStore } from '@/stores/cartStore';
4
5// Bien - utilise des selectors
6export const useCartInfo = () => {
7  return useCartStore(
8    useShallow((state) => ({
9      totalItems: state.getTotalItems(),
10      totalPrice: state.getTotalPrice(),
11      itemsCount: state.items.length,
12    }))
13  );
14};
15
16// Bien - valeurs uniques
17export const useCartIsOpen = () => useCartStore((state) => state.isOpen);
18
19// Mauvais - le store entier
20// export const useEntireStore = () => useCartStore();

Sans useShallow, chaque rendu créerait un nouvel objet, et Zustand 5 le considérerait comme un changement. Une valeur unique, comme isOpen, n'a pas besoin de cette protection.

Pattern d'abonnement

La méthode subscribe appelle une fonction à chaque changement d'état, en dehors du cycle de rendu. Elle reçoit le nouvel état et l'état précédent :

1// hooks/useStoreSubscription.ts
2import { useEffect } from 'react';
3import { useCartStore } from '@/stores/cartStore';
4
5export const useCartAnalytics = () => {
6  useEffect(() => {
7    const unsubscribe = useCartStore.subscribe(
8      (state, prevState) => {
9        const items = state.items;
10        const prevItems = prevState.items;
11        // Suit les analytics quand le panier change
12        if (items.length > prevItems.length) {
13          // Article ajouté
14          console.log('Item added to cart');
15        } else if (items.length < prevItems.length) {
16          // Article retiré
17          console.log('Item removed from cart');
18        }
19      }
20    );
21
22    return unsubscribe;
23  }, []);
24};

Le hook compare la longueur de la liste avant et après le changement, et la fonction renvoyée par subscribe nettoie l'abonnement. La variante avec un selector séparé en premier argument exige le middleware subscribeWithSelector.

Tests

Tester un store

Un store est un simple hook, donc tu le testes avec renderHook de Testing Library, et setState réinitialise l'état avant chaque test :

1// __tests__/stores/counterStore.test.ts
2import { renderHook, act } from '@testing-library/react';
3import { useCounterStore } from '@/stores/counterStore';
4
5describe('Counter Store', () => {
6  beforeEach(() => {
7    useCounterStore.setState({ count: 0 });
8  });
9
10  it('should increment count', () => {
11    const { result } = renderHook(() => useCounterStore());
12
13    act(() => {
14      result.current.increment();
15    });
16
17    expect(result.current.count).toBe(1);
18  });
19
20  it('should decrement count', () => {
21    const { result } = renderHook(() => useCounterStore());
22
23    act(() => {
24      result.current.increment();
25      result.current.decrement();
26    });
27
28    expect(result.current.count).toBe(0);
29  });
30
31  it('should reset count', () => {
32    const { result } = renderHook(() => useCounterStore());
33
34    act(() => {
35      result.current.increment();
36      result.current.increment();
37      result.current.reset();
38    });
39
40    expect(result.current.count).toBe(0);
41  });
42});

Chaque test repart de zéro grâce à beforeEach, donc les résultats ne fuient pas d'un test à l'autre.

Bonnes pratiques

1. Organisation des stores

Découpe l'état par thème au lieu de construire un seul magasin pour tout :

1// Bien - découpe les stores par thème
2// stores/
3//   ├── userStore.ts      // Authentification et profil utilisateur
4//   ├── cartStore.ts      // Panier
5//   ├── productsStore.ts  // Catalogue de produits
6//   └── appStore.ts       // État global de l'application
7
8// Mauvais - un store géant
9// stores/globalStore.ts  // Tout au même endroit

Les petits stores sont plus faciles à tester, et aucun composant ne s'abonne à des données qu'il n'utilise pas.

2. Bonnes pratiques TypeScript

Écris l'interface de l'état avant l'implémentation et passe le type en générique :

1// Bien - définis des interfaces
2interface UserState {
3  user: User | null;
4  login: (email: string, password: string) => Promise<void>;
5}
6
7// Bien - utilise des types génériques
8export const useUserStore = create<UserState>()((set) => ({
9  //
10}));

Ainsi, TypeScript vérifie chaque action et chaque champ.

3. Gestion des erreurs

Chaque action asynchrone doit définir loading et enregistrer l'erreur dans l'état :

1// Bien - gère les erreurs dans le store
2const fetchData = async () => {
3  set({ loading: true, error: null });
4
5  try {
6    const data = await api.fetchData();
7    set({ data, loading: false });
8  } catch (error) {
9    set({
10      error: error instanceof Error ? error.message : 'Unknown error',
11      loading: false
12    });
13  }
14};

Le composant se contente de lire error et loading, il n'a pas besoin de son propre try/catch.

Résumé

Zustand avec Next.js forme une combinaison puissante pour gérer l'état de l'application :

  1. Minimalist API - simple et lisible
  2. TypeScript-first - support complet des types
  3. SSR-friendly - fonctionne avec le Server-Side Rendering
  4. Performance - optimisations automatiques
  5. Middleware - un riche écosystème d'extensions

Mon conseil : récupère les données serveur dans les Server Components, garde Zustand pour l'état de l'interface et lis-le toujours avec des selectors. Dans la leçon suivante, tu découvriras les patterns avancés des Server Actions, qui enregistrent les données côté serveur.

Retiens : Zustand est le magasin d'état léger de la Métropole, un seul hook sans provider et sans le boilerplate de Redux.

Code de cette leçon : App.tsx
1import React, { useState, useCallback } from 'react';
2
3// Store simulé façon Zustand (Sandpack ne supporte pas le package zustand)
4function createStore<T>(initialState: T) {
5  let state = initialState;
6  const listeners = new Set<() => void>();
7
8  return {
9    getState: () => state,
10    setState: (partial: Partial<T> | ((s: T) => Partial<T>)) => {
11      const nextPartial = typeof partial === 'function' ? partial(state) : partial;
12      state = { ...state, ...nextPartial };
13      listeners.forEach(l => l());
14    },
15    subscribe: (listener: () => void) => {
16      listeners.add(listener);
17      return () => listeners.delete(listener);
18    },
19  };
20}
21
22interface Task {
23  id: number;
24  title: string;
25  completed: boolean;
26  priority: 'low' | 'medium' | 'high';
27}
28
29interface TodoState {
30  tasks: Task[];
31  filter: 'all' | 'active' | 'completed';
32}
33
34const store = createStore<TodoState>({
35  tasks: [
36    { id: 1, title: 'Configurer le store Zustand', completed: true, priority: 'high' },
37    { id: 2, title: 'Ajouter des middlewares (devtools, persist)', completed: false, priority: 'medium' },
38    { id: 3, title: "Implémenter l'hydratation SSR", completed: false, priority: 'high' },
39    { id: 4, title: 'Écrire des tests unitaires', completed: false, priority: 'low' },
40  ],
41  filter: 'all',
42});
43
44function useStore<R>(selector: (s: TodoState) => R): R {
45  const [, forceUpdate] = useState(0);
46  React.useEffect(() => {
47    return store.subscribe(() => forceUpdate(c => c + 1));
48  }, []);
49  return selector(store.getState());
50}
51
52const priorityColors = { high: '#f44336', medium: '#ff9800', low: '#4caf50' };
53
54const ZustandDemo = () => {
55  const tasks = useStore(s => s.tasks);
56  const filter = useStore(s => s.filter);
57  const [newTask, setNewTask] = useState('');
58  const [newPriority, setNewPriority] = useState<Task['priority']>('medium');
59  const [showCode, setShowCode] = useState(false);
60
61  const filteredTasks = tasks.filter(t => {
62    if (filter === 'active') return !t.completed;
63    if (filter === 'completed') return t.completed;
64    return true;
65  });
66
67  const addTask = () => {
68    if (!newTask.trim()) return;
69    store.setState(s => ({
70      tasks: [...s.tasks, { id: Date.now(), title: newTask, completed: false, priority: newPriority }]
71    }));
72    setNewTask('');
73  };
74
75  const toggleTask = (id: number) => {
76    store.setState(s => ({
77      tasks: s.tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
78    }));
79  };
80
81  const removeTask = (id: number) => {
82    store.setState(s => ({ tasks: s.tasks.filter(t => t.id !== id) }));
83  };
84
85  const setFilter = (f: TodoState['filter']) => store.setState({ filter: f });
86
87  const stats = {
88    total: tasks.length,
89    completed: tasks.filter(t => t.completed).length,
90    active: tasks.filter(t => !t.completed).length,
91  };
92
93  return (
94    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
95      <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Next.js + Zustand</h1>
96      <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>Gestion d'état légère</p>
97
98      <div style={{ maxWidth: '600px', margin: '0 auto' }}>
99        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '10px', marginBottom: '16px' }}>
100          {[
101            { label: 'Total', value: stats.total, color: '#7c4dff' },
102            { label: 'Actives', value: stats.active, color: '#ff9800' },
103            { label: 'Terminées', value: stats.completed, color: '#4caf50' },
104          ].map(s => (
105            <div key={s.label} style={{ background: 'rgba(255,255,255,0.05)', padding: '16px', borderRadius: '8px', textAlign: 'center' }}>
106              <div style={{ color: s.color, fontSize: '24px', fontWeight: 'bold' }}>{s.value}</div>
107              <div style={{ color: '#888', fontSize: '12px' }}>{s.label}</div>
108            </div>
109          ))}
110        </div>
111
112        <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
113          <input value={newTask} onChange={e => setNewTask(e.target.value)} placeholder="Nouvelle tâche..."
114            onKeyDown={e => e.key === 'Enter' && addTask()}
115            style={{ flex: 1, padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }} />
116          <select value={newPriority} onChange={e => setNewPriority(e.target.value as Task['priority'])}
117            style={{ padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }}>
118            <option value="high">Élevée</option>
119            <option value="medium">Moyenne</option>
120            <option value="low">Basse</option>
121          </select>
122          <button onClick={addTask} style={{ padding: '10px 16px', background: '#7c4dff', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer' }}>Ajouter</button>
123        </div>
124
125        <div style={{ display: 'flex', gap: '6px', marginBottom: '12px' }}>
126          {(['all', 'active', 'completed'] as const).map(f => (
127            <button key={f} onClick={() => setFilter(f)}
128              style={{ padding: '6px 14px', background: filter === f ? '#7c4dff' : 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '4px', color: '#fff', cursor: 'pointer', fontSize: '12px' }}>
129              {f.charAt(0).toUpperCase() + f.slice(1)}
130            </button>
131          ))}
132        </div>
133
134        <div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '10px', overflow: 'hidden' }}>
135          {filteredTasks.map(task => (
136            <div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '12px 16px', borderBottom: '1px solid #1a1a2e' }}>
137              <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
138              <span style={{ width: '8px', height: '8px', borderRadius: '50%', background: priorityColors[task.priority] }} />
139              <span style={{ flex: 1, textDecoration: task.completed ? 'line-through' : 'none', color: task.completed ? '#555' : '#fff' }}>{task.title}</span>
140              <button onClick={() => removeTask(task.id)} style={{ background: 'none', border: 'none', color: '#f44336', cursor: 'pointer', fontSize: '16px' }}>x</button>
141            </div>
142          ))}
143          {filteredTasks.length === 0 && <p style={{ textAlign: 'center', color: '#555', padding: '20px' }}>Aucune tâche</p>}
144        </div>
145      </div>
146    </div>
147  );
148};
149
150export default ZustandDemo;

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Zustand est une bibliothèque pour :

  2. 2. Lequel des éléments suivants n'est PAS un middleware Zustand intégré ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un store Zustand pour gérer l'état du panier d'achat

  • Éditeur de code

    Implémente un store avec des actions et des selectors.

  • Clique dans l'ordre

    Remets dans l'ordre les étapes de configuration d'un store Zustand dans une application Next.js.

Articles utiles