
React #418 só depois das 21h: o servidor já estava amanhã
De dia, nada. A partir das 21h, todo carregamento do Painel e da página de demonstração do BolsoVerde, o app de controle de vendas que eu construo para pequenos vendedores (bolsoverde.com), registrava o erro #418 do React nos logs de produção: falha de hidratação. À meia-noite, sumia. Na tela, nenhum sintoma: nenhum botão quebrado, nenhum valor errado.
Um erro com hora marcada é quase uma confissão. Se ele começa às 21h em Brasília, ele começa à meia-noite em UTC.
A data que o servidor escreveu
Todo erro de hidratação tem a mesma forma: o HTML que o servidor gerou não é o HTML que o React gera no navegador ao assumir a página. Desde o React 18, uma diferença não é remendada nó a nó. O React descarta o HTML do servidor até o <Suspense> mais próximo e renderiza esse trecho inteiro de novo no navegador. O app não declara nenhum <Suspense>, então o trecho refeito era, na prática, a página toda.
Aqui, a diferença era uma data. O Painel tem uma gaveta de "Nova despesa" que fica na página desde o primeiro render, escondida, para abrir na hora em que o usuário toca no botão. O campo de data dela começava em "hoje":
function buildInitialState(): FormState {
return { type: "EXPENSE", amountText: "", description: "", date: getCurrentDate() };
}
E getCurrentDate() não tinha bug nenhum. Ela devolve o dia local de quem a executa, e de propósito:
export function getCurrentDate(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
O atalho comum, new Date().toISOString().slice(0, 10), devolve o dia em UTC, que em Brasília vira amanhã às 21h. Esse erro eu já tinha evitado.
O que eu não tinha considerado era quem executa a função primeiro. Numa página Next.js renderizada no servidor, o primeiro a montar aquele componente não é o navegador do usuário: é o servidor. E o servidor roda em UTC, que é o padrão em servidores na nuvem. O dia local dele não é o dia local de ninguém que usa o app.
Pouco antes das 22h de 25 de setembro, em Brasília, o servidor já vivia em 26 de setembro. A diferença que o React acusou foi exatamente essa:
+ 25/09/2026
- 26/09/2026
O + é o que o navegador renderizou; o -, o que veio no HTML do servidor. A data aparece como texto dentro do botão que abre o calendário, então era uma divergência de conteúdo, não de atributo.
A mesma causa aparecia em outro lugar, só que um mês por vez. A página de resultado começa no mês corrente; na última noite de cada mês, o servidor escrevia o mês seguinte no cabeçalho e o navegador, o mês certo.
Por que nenhum teste pegou
Os testes unitários rodam num processo só, com um relógio só. O servidor de desenvolvimento roda na mesma máquina que o navegador. Nos dois casos, servidor e navegador compartilhavam relógio e fuso, então geravam a mesma data a qualquer hora. O bug precisava de duas coisas que nunca coincidem em desenvolvimento: dois fusos horários diferentes e um horário entre 21h e meia-noite.
E ele não doía de forma visível. O React se recupera sozinho e segue. A própria documentação resume o custo: na melhor das hipóteses, um erro de hidratação deixa a página mais lenta; na pior, handlers de evento podem ser ligados aos elementos errados. Aqui era o caso bom, mas com dois custos reais. O primeiro é justamente o trabalho que a renderização no servidor existe para poupar: a cada carregamento naquelas três horas, o navegador refazia a página do zero. O segundo é menos óbvio. Um erro que se repete toda noite vira ruído no monitoramento. O time aprende a ignorá-lo, e o próximo erro de hidratação, talvez um do caso ruim, chega escondido no meio dele.
As correções tentadoras
Há jeitos rápidos de fazer o erro sumir. Eles funcionam no sentido estrito, mas não resolvem o problema.
suppressHydrationWarning no elemento. Silencia o aviso, e é aí que mora o risco. A documentação do React é explícita: com ele, o React não tenta corrigir o texto divergente. O HTML do servidor fica, e o usuário veria a data de amanhã no campo. Eu trocaria um erro no console por um dado errado na tela. Além disso, a supressão só vale um nível abaixo do elemento, e o texto está dentro do seletor de data compartilhado do app. Seria preciso suprimir o aviso num componente usado em todo formulário, escondendo também as divergências que eu quero ver.
Desligar a renderização no servidor da gaveta (dynamic(..., { ssr: false })). Funciona, mas joga fora a renderização no servidor de um componente inteiro para resolver um campo.
Fixar o fuso do servidor em America/Sao_Paulo. Isso só muda o problema de lugar. O app também tem versões em inglês e espanhol; qualquer pessoa fora do fuso de Brasília teria o mesmo bug, invertido. O relógio do servidor não é o relógio do usuário, e nenhum fuso fixo transforma um no outro.
Mandar o fuso do usuário para o servidor, num cookie ou numa preferência da conta. Parece a versão certa da ideia anterior, mas esbarra em dois limites. A primeira visita não traz essa informação: o fuso do navegador só é conhecido depois que algum JavaScript rodou nele. E uma preferência salva envelhece: quem viaja continua vendo o dia de casa até alguém atualizar a conta. Quem sabe que dia é para o usuário é o aparelho na mão dele, naquele momento.
A alternativa clássica que resolve de verdade é renderizar duas vezes: um useState(false) que vira true num useEffect, e a data só aparece depois disso. Funciona. Mas o React já tem uma API feita para "um valor no servidor, outro no cliente", e o app já usava esse padrão em outros pontos, como a largura de tela do menu.
O padrão que ficou: snapshot de servidor nulo
useSyncExternalStore recebe três funções: uma para assinar mudanças, uma que lê o valor no cliente e uma que lê o valor no servidor. Essa última é usada no render do servidor e também durante a hidratação no navegador. Se ela devolve null, os dois lados renderizam o mesmo HTML. Logo depois de hidratar, o React renderiza de novo com o valor do cliente.
O hook inteiro tem menos de trinta linhas:
"use client";
import { useSyncExternalStore } from "react";
import { getCurrentDate, getCurrentMonth } from "@/lib/format";
// Não há o que escutar: o valor é lido de novo a cada render, o que basta para uma data.
function subscribe() {
return () => {};
}
const onServer = () => null;
/**
* A data local de hoje ("2026-09-25"), ou null enquanto o servidor renderiza e a página hidrata.
*
* O servidor roda em UTC: a partir das 21h em Brasília, lá já é amanhã. Um HTML renderizado com o
* "hoje" do servidor não bateria com o do navegador, e o React jogaria o HTML do servidor fora.
* Null dos dois lados mantém os dois iguais; a data do navegador vem logo em seguida.
*/
export function useToday(): string | null {
return useSyncExternalStore(subscribe, getCurrentDate, onServer);
}
/** O mês local ("2026-09"), null no servidor pelo mesmo motivo de useToday. */
export function useCurrentMonth(): string | null {
return useSyncExternalStore(subscribe, getCurrentMonth, onServer);
}
Dois detalhes que importam
O snapshot é uma string. O React compara leituras sucessivas com Object.is. Duas chamadas de getCurrentDate() no mesmo dia devolvem strings iguais, então o valor é estável. Se o hook devolvesse um new Date(), cada leitura seria um objeto novo, e o React acusaria que o resultado de getSnapshot precisa ser cacheado para não entrar em loop.
A assinatura não faz nada. Não existe um evento "o dia mudou" para escutar; o valor é relido a cada render. Isso teve um efeito colateral bom. Antes, a data padrão ia para o estado inicial do formulário e ficava congelada: quem abrisse o app às 23h50 e lançasse uma despesa à 0h10 lançava com a data de ontem. Agora o padrão acompanha o calendário até o usuário escolher uma data.
Null obriga a decidir o que mostrar antes de saber o dia
O tipo string | null é o que torna a correção segura. Cada componente que usava a data agora precisa dizer o que mostra enquanto ela não existe, e o compilador aponta todos eles.
No formulário, o estado passou a guardar só o que o usuário escolheu. A data fica indefinida até ele escolher uma, e o valor exibido é resolvido a cada render (código simplificado):
// Antes: a data ia para o estado inicial, calculada por quem renderizasse primeiro.
const [state, setState] = useState<FormState>({ type: "EXPENSE", date: getCurrentDate() });
// Depois: o estado guarda só a escolha do usuário; o "hoje" vem do hook.
const [state, setState] = useState<FormState>({ type: "EXPENSE" }); // date: undefined
const today = useToday();
const date = state.date ?? today ?? "";
O campo só fica vazio no HTML do servidor, dentro de uma gaveta fechada que ninguém vê.
Na página de resultado, o mês corrente vem do hook, e nada é buscado antes de ele existir:
const currentMonth = useCurrentMonth();
// Indefinido até o usuário navegar para outro mês.
const [ownMonth, setMonth] = useState<string | undefined>(undefined);
const month = controlledMonth ?? ownMonth ?? currentMonth;
useEffect(() => {
if (!month) return; // ainda hidratando: não há mês para buscar
// ...busca o resultado do mês
}, [fetchStatement, month]);
Enquanto o mês é null, o cabeçalho fica sem rótulo, como o Painel já ficava enquanto os dados carregavam, e navegar entre meses, recarregar e exportar não fazem nada. Isso dura só até o render logo depois da hidratação, e em nenhum momento aparece um mês errado.
Reproduzindo de propósito
Corrigir sem reproduzir seria apostar. O bug precisa de servidor e navegador em fusos diferentes, então eu forcei os dois a discordar:
- o servidor de desenvolvimento subiu com
TZ=UTC npm run dev; - o navegador do Playwright ficou em
America/Sao_Paulo; - para a virada de mês, sem esperar o dia 30 às 21h, eu movi só o relógio do navegador com
page.clock.setFixedTime.
// crawl-hydration.mjs. Rodar com o servidor em UTC: TZ=UTC npm run dev
import { chromium } from "@playwright/test";
const BASE_URL = "http://localhost:3031";
const PAGES = ["/", "/demo", "/resultado"]; // no meu caso, 24 rotas
const browser = await chromium.launch();
const context = await browser.newContext({ timezoneId: "America/Sao_Paulo" });
// Rotas logadas: injetar o cookie de sessão com context.addCookies(...)
const page = await context.newPage();
// Opcional: mover só o relógio do navegador. O servidor continua no relógio real.
await page.clock.setFixedTime(new Date("2026-08-20T12:00:00-03:00"));
const failures = [];
page.on("pageerror", (error) => failures.push(`${page.url()}\n${error.message}`));
for (const path of PAGES) {
await page.goto(`${BASE_URL}${path}`);
await page.waitForLoadState("networkidle");
}
console.log(failures.length ? failures.join("\n\n") : "nenhuma divergência");
await browser.close();
Um detalhe: no meu crawler, a divergência chegou como pageerror, não como console.error. Um script que só escutasse o console teria dito que estava tudo certo.
O crawler passou por 24 páginas, 8 públicas e 16 logadas com uma conta de teste. Com o relógio real, pouco antes das 22h, falharam exatamente duas: o Painel e a demonstração, com +25/09/2026 -26/09/2026. Com o relógio do navegador movido para agosto, a página de resultado também falhou, com +Agosto 2026 -Setembro 2026. Nenhuma outra. Depois da correção, o mesmo crawler não encontrou divergência em nenhuma das 24, com qualquer um dos dois relógios.
O hook também ganhou testes, mas eles provam outra coisa. Este garante que o HTML do servidor nunca carrega a data, mesmo no pior momento do mês:
function Probe() {
const today = useToday() ?? "no date";
const month = useCurrentMonth() ?? "no month";
return <p>{`${today} / ${month}`}</p>;
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
// 22h30 em Brasília no último dia do mês: em UTC, já é outubro.
vi.setSystemTime(new Date("2026-09-30T22:30:00-03:00"));
});
it("não escreve nada que dependa da data no HTML do servidor", () => {
expect(renderToString(<Probe />)).toContain("no date / no month");
});
O teste unitário prova o contrato do hook. Quem provou que o bug acabou foi o crawler, porque só ele roda com dois relógios diferentes.
Do crawler para o CI
Um crawler rodado uma vez prova que o bug acabou naquele dia. Ele não impede o próximo componente de pôr um getCurrentDate() no primeiro render. Para isso, a discordância entre os relógios precisava virar parte da suíte que roda em todo pull request.
O fuso sozinho não serve no CI: a discordância só existe depois das 21h, e o pipeline roda a qualquer hora. A saída é a mesma do teste de virada de mês, só que permanente. O servidor fica no relógio real, e o navegador volta 45 dias. Mais de 31 dias garante que os dois discordam do dia e do mês, a qualquer hora, em qualquer data. Num build de produção, o erro chega minificado, apontando para react.dev/errors/418, por isso a expressão regular:
const BROWSER_CLOCK_SHIFT_MS = 45 * 24 * 60 * 60 * 1000;
// Builds de produção reportam a divergência como erro minificado, com link para react.dev/errors.
const HYDRATION_ERROR = /hydrat|react\.dev\/errors\/(418|423|425)\b/i;
async function hydrationErrorsOn(page: Page, path: string): Promise<string[]> {
const errors: string[] = [];
page.on("pageerror", (error) => {
if (HYDRATION_ERROR.test(error.message)) errors.push(error.message);
});
// O servidor fica no relógio real; o navegador volta 45 dias.
await page.clock.setFixedTime(new Date(Date.now() - BROWSER_CLOCK_SHIFT_MS));
await page.goto(path);
await page.waitForLoadState("networkidle");
return errors;
}
for (const path of SIGNED_IN_PAGES) {
test(`${path} hydrates without a mismatch`, async ({ page }) => {
expect(await hydrationErrorsOn(page, path)).toEqual([]);
});
}
O spec visita 28 páginas, 12 públicas e 16 logadas. Um teste de regressão só vale se falhar quando o bug volta, então eu o reintroduzi de propósito: fiz o snapshot de servidor do hook ler o relógio do servidor de novo. Falharam exatamente cinco páginas: o Painel, a demonstração nas três línguas e a página de resultado, as mesmas que a correção original tinha resolvido. Com o hook restaurado, passaram as 28.
Houve uma ironia nesse caminho. A suíte E2E já fixava o relógio do navegador em junho de 2025 nos testes do Painel desde julho, para casar com os dados de exemplo dos serviços simulados. A condição do bug estava montada a cada execução da suíte. Só faltava alguém escutar o erro.
Por que isso importa
"Hoje" parece um valor, mas é uma pergunta incompleta: hoje onde? No navegador, a resposta é óbvia. No servidor, não existe usuário; existe uma máquina num fuso que ninguém escolheu pensando em quem lê a página. Qualquer coisa derivada do relógio que entre no primeiro HTML é um palpite do servidor sobre o dia de outra pessoa.
A regra que ficou no projeto é curta: o que depende da hora atual e aparece antes de os dados chegarem vem do navegador, e o servidor renderiza "ainda não sei". A lição mais geral vale além de datas. Um bug que só existe quando dois ambientes discordam não aparece em nenhum ambiente onde eles concordam. Para enxergá-lo, é preciso forçar a discordância em vez de esperar dar 21h. E, depois de corrigido, essa discordância precisa ficar no CI, para que o próximo bug da mesma família quebre um pull request, e não a produção.
Comentários
Carregando comentários...