Si ton premier rendu dépend de document, matchMedia, location.hash, localStorage ou d'un cookie, il te suffit d'une seule valeur qui diffère pour déclencher un hydration mismatch — ces API sont absentes pendant le SSR et déjà bien réelles au premier rendu côté client. tsc, ESLint et une suite de tests unitaires en environnement Node passent quand même au vert, parce qu'aucun d'eux n'effectue jamais de rendu d'hydratation. Deux correctifs honnêtes : initialiser après le montage (simple, une frame de flash), ou lire la valeur côté serveur (pas de flash, mais un aller-retour par cookie).
Le symptôme
Sidebar d'admin. Elle peut se replier en une barre d'icônes, et le choix est persisté. Tu navigues vers une autre page d'admin et la barre reste dépliée un instant, puis se referme d'un coup — avec en prime un hydration warning dans la console.
Tout était au vert. tsc --noEmit : exit 0. eslint : exit 0. 327 tests unitaires qui passent. Le bug n'était visible qu'avec mes propres yeux, dans un navigateur, au deuxième chargement de page.
Ce que je croyais
Je croyais qu'« API client-only » voulait dire « indisponible tant que useEffect n'a pas tourné ». Du coup, lire document.cookie directement pendant le rendu me semblait à peu près sûr : le serveur prendrait la branche typeof document === "undefined", le client prendrait la vraie branche, et React réglerait la différence.
C'est faux d'une manière bien précise, et cette manière précise est tout l'enjeu.
Le mécanisme réel
Le HTML rendu par le serveur arrive. React fait ensuite un hydration render — il ré-exécute ton arbre de composants côté client et attend que la sortie corresponde exactement au HTML reçu. Ce rendu-là se produit dans un vrai navigateur. document existe. window.matchMedia existe. location.hash est renseigné. localStorage est lisible.
Donc la branche que tu as écrite comme « la branche serveur » n'est empruntée qu'une seule fois, pendant le SSR. Au tout premier rendu client — celui qui doit correspondre — tu récupères la vraie valeur. Si cette vraie valeur diffère de la valeur par défaut du serveur, les arbres divergent : soit React émet un warning et corrige le texte, soit il jette le HTML du serveur et refait le rendu côté client depuis la Suspense boundary la plus proche — la racine entière, s'il n'y en a pas. Le flash que tu vois, c'est cette correction.
La version naïve du bandeau cookies ressemble à ça, et elle passe le type-check sans broncher :
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;Serveur : "ssr" → ne rend rien. Premier rendu client : "none" → rend un bandeau en fixed. Mismatch garanti, à chaque première visite.
Correctif (a) : initialiser après le montage
Pars d'une valeur que le serveur peut produire lui aussi, puis corrige-la dans un effect. Extrait de levain-template/src/components/sections/auth/AccountDashboard.tsx :
// Start on "orders" so the server render and the first client render agree
// (window.location.hash is unavailable on the server). A `#favorites` (etc.)
// deep-link is honoured just after mount via the effect below, which keeps
// hydration in sync at the cost of one imperceptible tab switch.
const [tab, setTab] = useState<Tab>("orders");
useEffect(() => {
const hash = window.location.hash.replace("#", "");
const valid: Tab[] = ["profile", "orders", "addresses", "favorites"];
if ((valid as string[]).includes(hash)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only post-mount seed to keep SSR/CSR hydration in sync
setTab(hash as Tab);
}
}, []);Note le commentaire de disable. Avec eslint-config-next 16.2.10 (qui embarque eslint-plugin-react-hooks 7), react-hooks/set-state-in-effect signale exactement cette forme, parce que dans le cas général c'est un rendu gaspillé. Ici c'est délibéré, donc ça mérite un commentaire qui explique pourquoi.
Quand tu ne veux pas te battre avec le linter, useSyncExternalStore fournit la même idée sous forme de primitive — getServerSnapshot, c'est littéralement « la valeur à utiliser pour le SSR et pour le rendu d'hydratation » :
// booking-template/src/lib/useIsMobile.ts
function subscribe(onChange: () => void): () => void {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function getSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}React utilise getServerSnapshot pour la passe d'hydratation, puis bascule sur getSnapshot juste après. Même forme, pas de bagarre avec le linter, et l'abonnement aux changements est offert. Le bandeau cookies dans booking-template/src/components/landing/CookieBanner.tsx utilise une sentinelle plutôt qu'un booléen :
function readConsent(): string {
if (typeof document === "undefined") return "ssr";
const hit = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
return hit ? (hit.split("=")[1] ?? "none") : "none";
}
const getServerSnapshot = () => "ssr";"ssr" ne rend rien, donc le rendu d'hydratation est byte-identical à celui du serveur. Le bandeau apparaît un tick plus tard. Pour une barre de consentement, ça passe très bien.
Correctif (b) : lire la valeur côté serveur
Pour la sidebar, ça ne passait pas — une barre qui se déplie visiblement à chaque navigation donne l'impression que c'est cassé. La valeur part donc dans un cookie que le serveur peut lire :
// booking-template/src/lib/admin/collapsed.ts
export const ADMIN_COLLAPSED_COOKIE = "vs-admin-collapsed";
export async function getAdminCollapsed(): Promise<boolean> {
const { cookies } = await import("next/headers");
return (await cookies()).get(ADMIN_COLLAPSED_COOKIE)?.value === "1";
}Le layout d'admin l'appelle, fait passer la valeur par un context provider, et la sidebar initialise son propre state à partir de là :
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());Maintenant le SSR et l'hydratation calculent tous les deux true. Pas de mismatch, pas de flash. Le même point de couture gère le thème : le layout de locale lit vs-theme / vs-mode dans le cookie jar et pose data-theme / data-mode directement sur <html>, si bien que la première frame peinte est déjà au bon thème.
Le compromis est réel et mérite d'être dit clairement. (a) est local — un seul fichier, aucun changement côté serveur, et ça peut flasher. (b) ne flashe jamais mais exige que la valeur vive dans un cookie, ce qui implique une écriture côté client, une navigation ou un reload pour prendre effet, et un rendu dynamique sur cette route. Prends (b) quand le flash décale le layout ou se voit sur l'image de marque ; prends (a) pour tout le reste.
Il y a une troisième option qu'on oublie : ne pas brancher en JS du tout. Le Reveal de la landing garde son état caché en CSS, conditionné à html.has-js, et laisse prefers-reduced-motion le désactiver dans une media query — aucun appel à useReducedMotion, donc rien qui puisse diverger.
Pourquoi la CI était verte
Parce que rien, dans la CI, n'hydrate.
booking-template/vitest.config.ts déclare environment: "node" et include: ["tests/**/*.test.ts"] — du .ts uniquement, pas de .tsx. Ces 327 tests exercent des helpers purs : calcul de disponibilité, règles de coupons, couverture des clés i18n. Ils ne peuvent pas observer un hydration mismatch, parce qu'ils ne montent jamais un arbre deux fois. tsc voit deux branches bien typées. ESLint voit un garde typeof parfaitement légal.
Une CI verte m'a dit que le code était bien formé. Elle n'a rien dit sur le fait que la fonctionnalité marchait.
La checklist
Chacun de ces éléments est undefined/absent pendant le SSR et bien réel au premier rendu client. Si l'un d'eux décide de ce que tu retournes, arrête-toi et choisis (a) ou (b) :
document— y comprisdocument.cookiewindow,window.location.search,window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)et tout ce qui est bâti dessus,useReducedMotioncomprisnavigator(language,userAgent,onLine)Date.now()/new Date()quand ça alimente la sortie rendueMath.random()et tout id non seedé
Grep tout ça dans les corps de rendu et les initialiseurs de useState. Ce grep en a trouvé quatre dans mon propre code en un après-midi.
C'est le pattern que je livre dans le booking template, démo en ligne sur booking.violettadev.com.