frontend / nextjs
État client dans l'App Router
Explication
Ce que vous allez apprendre
- Comprendre pourquoi
"use client"propage une frontière à tout ce qui est importé dedans - Composer un Client Component avec des Server Components via la prop
children - Créer un Context React côté client pour un thème clair/sombre
- Reconnaître les limites d'un Context pour un état global fréquemment modifié
- Utiliser une librairie légère (Zustand) pour un état partagé sans re-render global
Dans quel contexte ?
Une application e-commerce a besoin d'un panier d'achat visible et modifiable depuis de nombreux endroits de l'interface : le header, la page produit, la page de paiement. Un développeur commence par un simple Context React, puis remarque que CHAQUE ajout au panier fait re-render l'intégralité du header, y compris des éléments qui n'affichent même pas le contenu du panier. Cette leçon explique pourquoi ce ralentissement apparaît et comment le corriger avec le bon outil d'état.
Une frontière qui se propage
Commençons par bien comprendre ce que fait "use client". Ce n'est pas juste une annotation sur un fichier isolé : c'est une FRONTIÈRE. Dès qu'un composant est marqué ainsi, tout ce qu'il importe directement devient aussi exécutable côté navigateur.
Cette propagation a une conséquence pratique importante. Placer cette directive trop haut dans l'arbre de composants transforme silencieusement une grande partie de l'application en code client, annulant les bénéfices des Server Components vus dans la première leçon.
Heureusement, il existe une technique pour limiter les dégâts : le "children". Un Client Component (comme un ThemeProvider) peut recevoir des Server Components en tant que children, sans que ces derniers ne deviennent client pour autant.
Concrètement, ça veut dire quoi ? Le layout racine reste un Server Component, tout en enveloppant son contenu dans un fournisseur de contexte client. C'est une composition, pas une contamination automatique de tout le sous-arbre.
Prérequis
Cette leçon suppose que tu es à l'aise avec la distinction Server/Client Component vue dans la toute première leçon du cours : ici, on explore ses conséquences pratiques sur la gestion d'état.
Maintenant, une question naturelle se pose : pourquoi le Context React doit-il forcément être client ? Il repose sur useState/useContext, des hooks qui n'existent que dans le monde interactif du navigateur.
Un exemple aide à comprendre. Un thème clair/sombre doit réagir à un clic utilisateur, ce qui nécessite du JavaScript exécuté dans le navigateur — donc forcément un Client Component.
Une fois le Context maîtrisé, il a quand même une limite à connaître pour un état plus complexe. Pour un panier d'achat partagé entre de nombreux composants, chaque changement de valeur re-render TOUS les composants qui consomment ce contexte, même ceux qui ne s'intéressent qu'à une petite partie.
Des librairies légères comme Zustand résolvent ce problème. Elles ne re-rendent que les composants qui utilisent réellement la portion de l'état qui a changé.
| Solution | Re-render à chaque changement | Cas d'usage |
|---|---|---|
useState local | Seulement ce composant | État isolé à un composant (compteur, input) |
| Context React | TOUS les composants consommateurs | État simple, peu de mises à jour (thème) |
| Zustand (ou équivalent) | Seulement les composants qui lisent la portion modifiée | État global fréquemment modifié (panier) |
Le piège courant à connaître pour finir : oublier d'hydrater un store client avec des données déjà chargées côté serveur. Ça mène à des états incohérents au premier affichage, tant que la synchronisation n'est pas faite explicitement.
Piège fréquent
Un store Zustand du panier initialisé vide côté client, alors que le serveur avait déjà chargé son contenu depuis la base de données, affiche un panier vide pendant une fraction de seconde puis "saute" au bon contenu. Toujours hydrater le store avec les données serveur dès le montage, comme dans le composant CartHydrator ci-contre.
Commandes & code
État client dans l'App Router
// Frontière "use client" — tout ce qui est importé DEDANS devient client
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>Compteur : {count}</button>;
}// Composer Server + Client via "children" — évite de rendre TOUT un sous-arbre client
// app/layout.tsx (Server Component)
import ThemeProvider from "./ThemeProvider"; // Client Component
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body>
{/* ThemeProvider est client, mais "children" reste composé de Server Components */}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}// components/ThemeProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext<{ theme: string; toggle: () => void } | null>(null);
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
<div data-theme={theme}>{children}</div>
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme doit être utilisé dans ThemeProvider");
return ctx;
}// Zustand pour un état client global partagé entre plusieurs composants
// lib/cart-store.ts
"use client";
import { create } from "zustand";
interface CartItem {
id: string;
quantity: number;
}
interface CartState {
items: CartItem[];
addItem: (id: string) => void;
removeItem: (id: string) => void;
}
export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (id) =>
set((state) => {
const existing = state.items.find((i) => i.id === id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { id, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
}));// Bonne pratique : injecter des données serveur en état initial d'un store client
"use client";
import { useEffect } from "react";
import { useCartStore } from "@/lib/cart-store";
export default function CartHydrator({ initialItems }: { initialItems: { id: string; quantity: number }[] }) {
useEffect(() => {
useCartStore.setState({ items: initialItems });
}, [initialItems]);
return null; // composant purement fonctionnel, pas de rendu visuel
}Résumé
"use client"marque une frontière : tout le sous-arbre importé devient exécutable côté navigateur.- Passer des Server Components en
children/props à des Client Components limite le JS envoyé. - Un Context React ne peut vivre que côté client dans l'App Router.
- Pour un état global complexe, une lib légère (Zustand, Jotai) évite le re-render global d'un Context.
Exercices pratiques
Mission : le header qui re-render à chaque article ajouté au panier
Objectif : Diagnostiquer un re-render global causé par un Context surchargé et migrer le panier vers un store adapté, sans transformer tout le layout en client.
Contexte
Le panier d'achat repose actuellement sur un CartContext React consommé par le header, la page produit et la page de paiement. Un profiler React montre que CHAQUE ajout d'article fait re-render l'intégralité du header, y compris le logo et les liens de navigation qui n'affichent pourtant jamais le contenu du panier. Par ailleurs, un développeur a ajouté "use client" directement dans app/layout.tsx pour pouvoir y utiliser ce contexte, ce qui a transformé tout le site en composants client.