Se o teu primeiro render depende de document, matchMedia, location.hash, localStorage ou de uma cookie, estás a um valor diferente de um hydration mismatch — essas APIs não existem durante o SSR e já são reais no primeiro render do cliente. tsc, o ESLint e uma suite de testes unitários em ambiente Node passam à mesma, porque nenhum deles chega a fazer um render de hydration. Duas correções honestas: semear depois do mount (simples, um frame de flash), ou ler o valor no servidor (sem flash, exige uma ida e volta com cookie).
O sintoma
Sidebar de admin. Pode colapsar para uma barra de ícones, e a escolha persiste. Navegas para outra página de admin e a barra fica expandida por um instante, depois fecha-se de repente — mais um hydration warning na consola.
Estava tudo verde. tsc --noEmit: exit 0. eslint: exit 0. 327 testes unitários a passar. O bug só era visível com os meus próprios olhos, num browser, no segundo carregamento da página.
O que assumi
Assumi que "API só de cliente" queria dizer "indisponível até o useEffect correr". Por isso ler document.cookie diretamente durante o render parecia mais ou menos seguro: o servidor seguiria o ramo typeof document === "undefined", o cliente seguiria o ramo real, e o React resolveria a diferença.
Isso está errado de uma forma específica, e essa forma específica é o ponto todo.
O mecanismo real
Chega o HTML renderizado no servidor. O React faz então um render de hydration — volta a correr a tua árvore de componentes no cliente e espera que o output coincida exatamente com o HTML recebido. Esse render acontece num browser a sério. document existe. window.matchMedia existe. location.hash está preenchido. localStorage é legível.
Ou seja, o ramo que escreveste como "o ramo do servidor" só é seguido uma vez, durante o SSR. No primeiríssimo render do cliente — o que tem de coincidir — recebes o valor real. Se o valor real diferir do default do servidor, as árvores divergem: o React ou avisa e corrige o texto, ou deita fora o HTML do servidor e volta a renderizar no cliente a partir do Suspense boundary mais próximo — a raiz inteira, se não existir nenhum. O flash que vês é essa correção.
A versão ingénua do cookie banner lê-se assim, e passa o type-check na perfeição:
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;Servidor: "ssr" → não renderiza nada. Primeiro render do cliente: "none" → renderiza um banner fixo. Mismatch, garantido, em todas as primeiras visitas.
Correção (a): semear depois do mount
Começa por um valor que o servidor também consiga produzir, e corrige-o num effect. 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);
}
}, []);Repara no comentário de disable. No eslint-config-next 16.2.10 (que arrasta o eslint-plugin-react-hooks 7), a regra react-hooks/set-state-in-effect assinala exatamente esta forma, porque no caso geral é um render desperdiçado. Aqui é deliberado, por isso leva um comentário a explicar porquê.
Quando não queres discutir com o linter, useSyncExternalStore é a mesma ideia mas enquanto primitiva — getServerSnapshot é literalmente "o valor a usar para o SSR e para o render de 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);
}O React usa getServerSnapshot na passagem de hydration, e muda para getSnapshot logo a seguir. Mesma forma, sem luta com o lint, e ainda ganhas a subscrição a mudanças de graça. O cookie banner em booking-template/src/components/landing/CookieBanner.tsx usa um sentinela em vez de um 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" não renderiza nada, por isso o render de hydration é byte a byte idêntico ao do servidor. O banner aparece um tick depois. Para uma faixa de consentimento isso serve.
Correção (b): lê-lo no servidor
Para a sidebar não servia — uma barra que visivelmente se descolapsa em cada navegação parece avariada. Por isso o valor passa para uma cookie que o servidor consegue ler:
// 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";
}O layout de admin chama-a, passa o valor por um context provider, e a sidebar semeia o seu próprio estado a partir dele:
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());Agora o SSR e a hydration calculam ambos true. Sem mismatch, sem flash. A mesma costura trata do tema: o layout de locale lê vs-theme / vs-mode do frasco de cookies e carimba data-theme / data-mode diretamente no <html>, por isso o primeiro frame pintado já vem com o tema certo.
O trade-off é real e vale a pena dizê-lo com clareza. (a) é local — um ficheiro, sem alterações no servidor, e pode dar flash. (b) nunca dá flash mas exige que o valor viva numa cookie, o que significa uma escrita no cliente, uma navegação ou um reload para fazer efeito, e um render dinâmico nessa rota. Usa (b) quando o flash desloca o layout ou é visível para a marca; usa (a) para tudo o resto.
Há uma terceira opção de que as pessoas se esquecem: não ramificar em JS de todo. O Reveal da landing guarda o seu estado escondido em CSS condicionado a html.has-js e deixa prefers-reduced-motion desativá-lo numa media query — sem chamada a useReducedMotion, portanto não há nada que possa divergir.
Porque é que o CI estava verde
Porque nada no CI hidrata.
booking-template/vitest.config.ts diz environment: "node" e include: ["tests/**/*.test.ts"] — só .ts, nada de .tsx. Esses 327 testes exercitam helpers puros: matemática de disponibilidade, regras de cupões, cobertura de chaves i18n. Não conseguem observar um hydration mismatch, porque nunca montam uma árvore duas vezes. O tsc vê os dois ramos bem tipados. O ESLint vê um guard typeof legal.
Um CI verde disse-me que o código estava bem formado. Não disse nada sobre se a funcionalidade funcionava.
A checklist
Cada um destes é undefined/inexistente durante o SSR e real durante o primeiro render do cliente. Se um deles decide o que devolves, para e escolhe (a) ou (b):
document— incluindodocument.cookiewindow,window.location.search,window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)e tudo o que é construído em cima disso,useReducedMotionincluídonavigator(language,userAgent,onLine)Date.now()/new Date()quando alimenta o output renderizadoMath.random()e qualquer id não semeado
Faz grep por estes dentro dos corpos de render e dos inicializadores de useState. Esse grep encontrou quatro destes no meu próprio código numa tarde.
Este é o padrão que envio no booking template, demo ao vivo em booking.violettadev.com.