Se il tuo primo render dipende da document, matchMedia, location.hash, localStorage o da un cookie, ti basta un solo valore diverso per beccarti un hydration mismatch — quelle API sono assenti durante l'SSR e già reali al primo render del client. tsc, ESLint e una suite di unit test in ambiente Node passano lo stesso, perché nessuno di loro esegue mai un render di hydration. Due fix onesti: inizializzare dopo il mount (semplice, un frame di flash), oppure leggere il valore lato server (niente flash, ma serve un giro attraverso un cookie).
Il sintomo
Sidebar dell'admin. Si può collassare a barra di icone, e la scelta viene persistita. Navighi verso un'altra pagina admin e la barra resta espansa per un attimo, poi si richiude di scatto — più un hydration warning in console.
Era tutto verde. tsc --noEmit: exit 0. eslint: exit 0. 327 unit test che passano. Il bug si vedeva solo con i miei occhi, in un browser, al secondo caricamento della pagina.
Cosa davo per scontato
Davo per scontato che "API client-only" volesse dire "non disponibile finché non gira useEffect". Quindi leggere document.cookie direttamente durante il render mi sembrava tutto sommato sicuro: il server avrebbe preso il ramo typeof document === "undefined", il client avrebbe preso il ramo vero, e React avrebbe sistemato la differenza.
È sbagliato in un modo ben preciso, e quel modo preciso è tutto il punto.
Il meccanismo vero
Arriva l'HTML renderizzato dal server. Poi React fa un hydration render — riesegue il tuo albero di componenti sul client e si aspetta che l'output combaci esattamente con l'HTML ricevuto. Quel render avviene in un browser vero. document esiste. window.matchMedia esiste. location.hash è valorizzato. localStorage è leggibile.
Quindi il ramo che hai scritto come "il ramo server" viene preso una volta sola, durante l'SSR. Al primissimo render del client — quello che deve combaciare — ti arriva il valore vero. Se il valore vero è diverso dal default del server, gli alberi divergono: o React emette un warning e ritocca il testo, oppure butta via l'HTML del server e rifà il render sul client a partire dal Suspense boundary più vicino — l'intera root, se non ce n'è nessuno. Il flash che vedi è quella correzione.
La versione ingenua del banner dei cookie è fatta così, e supera il type-check senza fiatare:
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;Server: "ssr" → non renderizza niente. Primo render del client: "none" → renderizza un banner in fixed. Mismatch garantito, a ogni prima visita.
Fix (a): inizializzare dopo il mount
Parti da un valore che anche il server può produrre, poi correggilo in un effect. Da 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);
}
}, []);Nota il commento di disable. Con eslint-config-next 16.2.10 (che si porta dietro eslint-plugin-react-hooks 7), react-hooks/set-state-in-effect segnala esattamente questa forma, perché nel caso generale è un render sprecato. Qui è voluto, quindi si merita un commento che spieghi perché.
Quando non hai voglia di litigare col linter, useSyncExternalStore è la stessa idea sotto forma di primitiva — getServerSnapshot è letteralmente "il valore da usare per l'SSR e per il render di hydration":
// 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 usa getServerSnapshot per la passata di hydration, poi passa subito dopo a getSnapshot. Stessa forma, nessuna rissa col linter, e la sottoscrizione ai cambiamenti te la porti a casa gratis. Il banner dei cookie in booking-template/src/components/landing/CookieBanner.tsx usa un sentinel invece di un booleano:
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" non renderizza niente, quindi il render di hydration è byte-identical a quello del server. Il banner compare un tick dopo. Per una striscia di consenso va benissimo.
Fix (b): leggerlo lato server
Per la sidebar non andava bene — una barra che si vede espandersi a ogni navigazione sembra rotta. Quindi il valore si sposta in un cookie che il server può leggere:
// 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";
}Il layout dell'admin la chiama, passa il valore attraverso un context provider, e la sidebar ci inizializza sopra il proprio state:
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());Adesso SSR e hydration calcolano entrambi true. Nessun mismatch, nessun flash. Lo stesso punto di giunzione gestisce il tema: il layout della locale legge vs-theme / vs-mode dal cookie jar e stampa data-theme / data-mode direttamente su <html>, così il primo frame disegnato è già del tema giusto.
Il trade-off è reale e vale la pena dirlo chiaramente. (a) è locale — un file solo, nessuna modifica lato server, e può flashare. (b) non flasha mai ma richiede che il valore viva in un cookie, il che significa una scrittura lato client, una navigazione o un reload perché faccia effetto, e un render dinamico su quella route. Usa (b) quando il flash sposta il layout o si vede a livello di brand; usa (a) per tutto il resto.
C'è una terza opzione che tutti si dimenticano: non fare proprio il branch in JS. Il Reveal della landing tiene il suo stato nascosto in CSS, condizionato a html.has-js, e lascia che prefers-reduced-motion lo disattivi in una media query — nessuna chiamata a useReducedMotion, quindi niente che possa divergere.
Perché la CI era verde
Perché nella CI non si idrata niente.
booking-template/vitest.config.ts dice environment: "node" e include: ["tests/**/*.test.ts"] — solo .ts, niente .tsx. Quei 327 test esercitano helper puri: calcoli di disponibilità, regole dei coupon, copertura delle chiavi i18n. Non possono osservare un hydration mismatch, perché non montano mai un albero due volte. tsc vede due rami ben tipizzati. ESLint vede una guardia typeof perfettamente legale.
La CI verde mi ha detto che il codice era ben formato. Non ha detto niente su se la feature funzionasse.
La checklist
Ognuna di queste cose è undefined/assente durante l'SSR e reale durante il primo render del client. Se una di loro decide cosa ritorni, fermati e scegli (a) o (b):
document— inclusodocument.cookiewindow,window.location.search,window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)e qualsiasi cosa costruita sopra,useReducedMotioncompresonavigator(language,userAgent,onLine)Date.now()/new Date()quando alimenta l'output renderizzatoMath.random()e qualunque id non seeded
Fai grep di questa roba dentro i corpi di render e negli initializer di useState. Quel grep ne ha trovati quattro nel mio stesso codice in un pomeriggio.
Questo è il pattern che spedisco nel booking template, demo live su booking.violettadev.com.