Erros de hidratação Next.js com localStorage: 3 correções


Como corrigir erros de hidratação localStorage em Next.js

  1. Identificar o acesso localStorage executado durante a renderização ou pré-renderização do lado do servidor.
  2. Remover leituras diretas de localStorage de useState inicializadores e escopo de componente de nível superior.
  3. Escolher uma correção: useEffect adiamento, useSyncExternalStore gancho ou uma abordagem baseada em cookies.
  4. Adiar lê com useEffect para casos simples em que um breve flash do estado padrão é aceitável.
  5. Implementar useSyncExternalStore com um getServerSnapshot para ganchos localStorage reutilizáveis ​​e sincronizados entre guias.
  6. Migrar a cookies para preferências acima da dobra, como tema ou localidade, que exigem zero FOUC.
  7. Verificar correção de hidratação correndo next build && next start contra a construção de produção.

Erros de hidratação causados ​​por localStorage em Next.js continuam sendo um problema comumente relatado entre desenvolvedores React que constroem com o App Router. A causa raiz é simples, mas fácil de ignorar: o servidor e o cliente discordam quanto ao estado inicial. A solução certa depende de compensações em torno do flash de conteúdo sem estilo, da correção do SSR e da complexidade arquitetônica. Este guia avalia três padrões distintos para resolver esses erros: o familiar useEffect abordagem, o frequentemente esquecido useSyncExternalStore hook do React 18 e uma estratégia de cookies legível pelo servidor que elimina totalmente o FOUC. Cada padrão inclui código funcional e uma análise concreta de quando ele se ajusta e quando não.

Este guia é direcionado a Next.js 15.x, React 18.x e Node.js 20.x. Todos os exemplos usam o App Router. O await cookies() A API requer Next.js 15 ou posterior; para Next.js 13/14, use cookies() sem await. useSyncExternalStore é enviado nativamente no React 18; um pacote de calços (use-sync-external-store) abrange o React 17.

Índice

Por que localStorage quebra a hidratação do Next.js

A hidratação é o processo em que o React, em execução no navegador, anexa manipuladores de eventos ao HTML renderizado pelo servidor e reconcilia essa marcação estática com a árvore DOM virtual que o cliente teria produzido. Para que a hidratação seja bem-sucedida, o HTML gerado no servidor deve corresponder, nó por nó, ao que o cliente renderiza em sua passagem inicial. Qualquer divergência desencadeia um erro de incompatibilidade de hidratação.

O window objeto não existe em Node.js. Porque localStorage é uma propriedade de windowqualquer acesso direto durante a renderização no servidor falhará. Os desenvolvedores atingiram um dos dois modos de falha. O primeiro é uma falha grave: ReferenceError: localStorage is not definedo que mata totalmente a renderização do servidor. No roteador de aplicativos, 'use client' componentes ainda são pré-renderizados no servidor durante next buildportanto, esse erro é acionado durante a pré-renderização do servidor, não no navegador. O segundo modo de falha é mais sutil e comum: um desenvolvedor envolve o acesso em um typeof window !== 'undefined' guard, que evita o travamento, mas faz com que o servidor seja renderizado com um valor padrão (digamos, 'light') enquanto o cliente lê um valor armazenado (digamos, 'dark') de localStorage. O React detecta a diferença e emite um aviso de incompatibilidade de hidratação. Este cenário exato foi relatado repetidamente em Estouro de pilhamuitas vezes confundindo os desenvolvedores que acreditam que a proteção deveria ser suficiente.

A causa raiz é simples, mas fácil de ignorar: o servidor e o cliente discordam quanto ao estado inicial.

O objetivo aqui é comparar três padrões que resolvem ambos os modos de falha, com uma análise honesta dos seus compromissos.

Aqui está o componente quebrado que aciona o erro:


'use client';
import { useState } from 'react';

export default function ThemeToggle() {
  const (theme, setTheme) = useState(
    () => localStorage.getItem('theme') ?? 'light'
  );
  
  

  return (
    <button onClick={() => {
      const next = theme === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', next);
      setTheme(next);
    }}>
      Current theme: {theme}
    button>
  );
}

Correndo next build && next start com este componente produz o ReferenceError durante a pré-renderização do servidor ou, se um typeof window check é adicionado ao inicializador, uma incompatibilidade de hidratação na produção.

O que realmente significa “incompatibilidade de hidratação” em Next.js?

Renderização de servidor versus renderização de cliente

Durante o SSR, Next.js executa a árvore de componentes React em Node.js para produzir uma string HTML. Next.js envia essa string para o navegador, onde o React constrói sua própria árvore DOM virtual e a compara com a marcação existente renderizada pelo servidor. Se as duas árvores divergirem no conteúdo do texto, nos valores dos atributos ou na estrutura da árvore, o React registrará um erro de hidratação. Na produção, o React 18 registra um erro de console e tenta a recuperação do DOM, mas ainda podem ocorrer falhas visuais, estado perdido ou interatividade interrompida. O modo de desenvolvimento adiciona verificações adicionais e rastreamentos de pilha, facilitando o diagnóstico de incompatibilidades.

Por que localStorage é um infrator comum

window.localStorage simplesmente não existe no tempo de execução do Node.js. Os navegadores expõem isso; Node.js não. O antipadrão mais comum é lê-lo dentro de um useState inicializador:

useState(() => localStorage.getItem('theme') ?? 'light')

Adicionando um typeof window !== 'undefined' guarda evita a colisão, mas introduz uma assimetria. O servidor sempre volta para 'light'. O cliente lê a preferência armazenada do usuário, que pode ser 'dark'. Reagir vê 'light' no servidor e 'dark' no cliente e a incompatibilidade é acionada. Esta não é uma preocupação teórica; é o mecanismo exato por trás da maioria dos erros de hidratação relacionados ao localStorage em aplicativos Next.js.

Correção 1: efeito pós-montagem com useEffect

Como funciona

A correção mais simples adia a leitura do localStorage até depois da montagem do componente. Servidor e cliente renderizam o mesmo padrão seguro. Depois que o componente for montado no navegador, um useEffect lê o valor armazenado e atualiza o estado, acionando uma nova renderização somente do cliente.

'use client';
import { useState, useEffect } from 'react';

export default function ThemeToggle() {
  const (theme, setTheme) = useState('light');

  useEffect(() => {
    try {
      const stored = localStorage.getItem('theme');
      if (stored === 'light' || stored === 'dark') {
        setTheme(stored);
      }
    } catch (err) {
      
      console.warn('(ThemeToggle) Could not read localStorage:', err);
    }
  }, ());

  const toggle = () => {
    const next = theme === 'light' ? 'dark' : 'light';
    try {
      localStorage.setItem('theme', next);
    } catch (err) {
      console.error('(ThemeToggle) Failed to write localStorage:', err);
    }
    setTheme(next);
  };

  return <button onClick={toggle}>Current theme: {theme}button>;
}

O servidor renderiza “Tema atual: light”. O cliente também renderiza “Tema atual: luz” durante a hidratação, para que as árvores combinem. Após a montagem, useEffect incêndios, lê 'dark' de localStorage, se presente, e atualiza o texto do botão. A hidratação dá certo.

Prós e Contras

Este padrão não requer dependências e usa apenas useState e useEffectambos abordados no tutorial oficial do React. No entanto, ele introduz um flash do estado padrão na primeira pintura, normalmente durando de um a dois quadros (menos de 50 ms em hardware moderno, mas visível se acionar uma mudança de layout ou troca de cores). Se o usuário armazenou 'dark'eles veem 'light' para aquela breve janela antes que o efeito seja executado e acione uma nova renderização. Este FOUC é a principal desvantagem. O padrão também causa duas renderizações na montagem: a renderização de hidratação inicial e a atualização de estado do efeito.

Bom ajuste

Essa abordagem funciona para preferências de baixo risco, onde um breve flash visual é aceitável, como configurações de UI não críticas ou sinalizadores de recursos que não afetam o layout. Também é adequado para protótipos e MVPs onde a simplicidade é mais importante do que o acabamento.

Correção 2: gancho useSyncExternalStore

Por que useSyncExternalStore existe

Reacção 18 introduzida useSyncExternalStore especificamente para assinar com segurança fontes de dados externas fora do gerenciamento de estado do React. O gancho aceita três argumentos: a subscribe função, um getSnapshot função para o cliente e um getServerSnapshot função que informa explicitamente ao React qual valor usar durante o SSR. Este terceiro argumento elimina a incompatibilidade por design, porque o React sabe que o instantâneo do servidor é intencionalmente diferente do que o cliente pode ler após a montagem.

Construindo um gancho useLocalStorage reutilizável

Salve este gancho como hooks/useLocalStorage.js (ou .ts) na raiz do seu projeto e importe-o usando o recurso integrado do Next.js @/ apelido: import useLocalStorage from '@/hooks/useLocalStorage'.

'use client';
import { useCallback, useRef, useSyncExternalStore } from 'react';

function dispatchStorageEvent(key, newValue) {
  window.dispatchEvent(
    new StorageEvent('storage', { key, newValue, storageArea: window.localStorage })
  );
}

function useLocalStorage(key, defaultValue) {
  
  
  const defaultRef = useRef(defaultValue);

  const subscribe = useCallback(
    (callback) => {
      if (typeof window === 'undefined') {
        
        return () => {};
      }
      const handler = (e) => {
        
        if (e.key === key || e.key === null) {
          callback();
        }
      };
      window.addEventListener('storage', handler);
      return () => window.removeEventListener('storage', handler);
    },
    (key)
  );

  const getSnapshot = useCallback(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? stored : defaultRef.current;
    } catch {
      return defaultRef.current;
    }
  }, (key));

  const getServerSnapshot = useCallback(
    () => defaultRef.current,
    ()
  );

  const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

  const setValue = useCallback(
    (newValue) => {
      try {
        localStorage.setItem(key, newValue);
        dispatchStorageEvent(key, newValue);
      } catch (err) {
        console.error(
          `(useLocalStorage) Failed to write key "${key}":`,
          err
        );
      }
    },
    (key)
  );

  return (value, setValue);
}

export default useLocalStorage;

O subscribe função escuta o nativo storage evento, que é acionado quando outra guia modifica localStorage. O nativo storage O evento não é acionado na guia que fez a alteração (por especificação do navegador). setValue despacha manualmente um sintético StorageEvent para notificar o assinante da guia atual. O check-in da chave subscribe (e.key === key) evita que chaves não relacionadas acionem atualizações. Tratar key como uma constante estável; mudando key dinamicamente causará uma nova assinatura e poderá retornar brevemente defaultValue. O getServerSnapshot retorna o valor padrão durante o SSR e getSnapshot lê de localStorage no cliente.

O componente de consumo torna-se mínimo:

'use client';
import useLocalStorage from '@/hooks/useLocalStorage';

export default function ThemeToggle() {
  const (theme, setTheme) = useLocalStorage('theme', 'light');

  const toggle = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return <button onClick={toggle}>Current theme: {theme}button>;
}

Prós, contras e quando alcançá-lo

Este padrão é seguro para hidratação por contrato. O getServerSnapshot O parâmetro torna o valor do servidor explícito e o React trata a transição sem erro de incompatibilidade. A sincronização entre guias é gratuita por meio do storage ouvinte de eventos. Quando o snapshot do cliente difere do snapshot do servidor, o React agenda uma nova renderização do cliente após a hidratação. FOUC ainda ocorre; useSyncExternalStoreA vantagem do é um contrato de hidratação explícito e sincronização cruzada integrada, não eliminação de flash.

FOUC ainda ocorre; A vantagem do useSyncExternalStore é um contrato de hidratação explícito e sincronização cruzada integrada, não eliminação de flash.

As desvantagens: o instantâneo do servidor ainda difere do valor armazenado, então os usuários veem um flash do padrão na primeira pintura, assim como acontece com useEffect. O gancho também requer a implementação subscribe, getSnapshote getServerSnapshot retornos de chamada e desenvolvedores não familiarizados com useSyncExternalStore pode achar mais difícil raciocinar sobre seu modelo de assinatura.

Esta é a escolha certa para qualquer aplicativo que trate o localStorage como um armazenamento ativo e assinável. Painéis com várias guias, ferramentas colaborativas e aplicativos com muitos componentes lendo o mesmo valor armazenado se beneficiam da sincronização integrada. Também funciona bem como um gancho reutilizável consumido por muitos componentes, porque a API retorna um (value, setter) tupla correspondente ao useState contrai e o comportamento de hidratação é explícito.

Correção 3: mova a fonte da verdade para cookies

Por que os cookies resolvem o problema do Flash

Os cookies viajam com cada solicitação HTTP. No Next.js App Router, os componentes e layouts do servidor podem ler cookies por meio de cookies() de next/headers. Isso significa que o servidor tem acesso às preferências do usuário antes de renderizar um único byte de HTML. O servidor renderiza a UI correta na primeira pintura e o cliente hidrata com esse mesmo valor. Não FOUC.

Passo a passo de implementação

No layout do servidor:

import { cookies } from 'next/headers';
import ThemeToggle from './components/ThemeToggle';

export default async function RootLayout({ children }) {
  
  
  const cookieStore = await cookies();
  const theme = cookieStore.get('theme')?.value ?? 'light';

  return (
    <html lang="en" data-theme={theme}>
      <body>
        <ThemeToggle initialTheme={theme} />
        {children}
      body>
    html>
  );
}

No componente cliente:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

function setCookie(name, value, days = 365) {
  if (typeof window === 'undefined') {
    throw new Error('setCookie must only be called in a browser context');
  }
  if (days <= 0) {
    throw new RangeError('days must be a positive number');
  }
  const encodedName = encodeURIComponent(name);
  const encodedValue = encodeURIComponent(value);
  const expires = new Date(
    Date.now() + days * 24 * 60 * 60 * 1000
  ).toUTCString();
  const secureFlag =
    window.location.protocol === 'https:' ? ';Secure' : '';
  document.cookie = `${encodedName}=${encodedValue};expires=${expires};path=/;SameSite=Lax${secureFlag}`;
}

export default function ThemeToggle({ initialTheme }) {
  
  
  
  const (theme, setTheme) = useState(initialTheme);
  const (isRefreshing, setIsRefreshing) = useState(false);
  const router = useRouter();

  const toggle = async () => {
    if (isRefreshing) return; 
    const next = theme === 'light' ? 'dark' : 'light';
    try {
      setIsRefreshing(true);
      setCookie('theme', next);
      setTheme(next); 
      router.refresh();
    } catch (err) {
      console.error('(ThemeToggle) Failed to set theme cookie:', err);
      
      setTheme(theme);
    } finally {
      setIsRefreshing(false);
    }
  };

  return (
    <button onClick={toggle} disabled={isRefreshing}>
      Current theme: {theme}
    button>
  );
}

O router.refresh() call aciona uma nova renderização da rota atual no lado do servidor, coletando o novo valor do cookie. Observação: router.refresh() faz uma solicitação ao servidor em cada chamada. Para atualizações de alta frequência, prefira uma ação do servidor que defina o cookie via cookies().set() de next/headersevitando a atualização completa do lado do cliente. Defina a duração do cookie de acordo com sua política de privacidade. Para preferências puramente funcionais, 365 dias é comum, mas verifique a conformidade com os regulamentos aplicáveis ​​(por exemplo, GDPR, Diretiva de Privacidade Eletrônica).

Prós e Contras

Esse padrão atinge zero flash: servidor e cliente concordam desde o primeiro byte, portanto não há divergência para reconciliar. No entanto, os cookies têm no máximo cerca de 4 KB cada, tornando-os inadequados para grandes cargas. A abordagem também requer atenção aos atributos dos cookies: SameSite e path deve ser definido corretamente. O Secure flag se aplica automaticamente quando a página é carregada por HTTPS (incluindo produção); sobre http://localhost durante o desenvolvimento, o sinalizador é omitido para evitar que o cookie seja descartado silenciosamente. Esse padrão adiciona mais infraestrutura do que as abordagens somente cliente, seja por meio de ações de servidor, rotas de API ou manuais. document.cookie gerenciamento. Cookies definidos através document.cookie são acessíveis por JS (não HttpOnly), o que é intencional aqui, pois o cliente precisa escrever o valor.

Os cookies são a escolha certa para tema, localidade ou qualquer preferência que afete a renderização acima da dobra. Embora a eliminação do FOUC seja um requisito difícil do produto, os cookies são o único padrão entre esses três que atende.

Tabela de comparação: qual correção você deve escolher?

CritériosuseEffectuseSyncExternalStoreBiscoitos
Seguro para hidrataçãoSimSimSim
Flash de padrão (FOUC)SimSimNão
Primeira pintura correta para SSRNãoNãoSim
Sincronização entre guiasManualIntegradoNa recarga/navegação
Limite de tamanho de dados~5 MB (depende do navegador)~5 MB (depende do navegador)~4 KB por cookie
ComplexidadeBaixoMédioMédio a alto
Melhor paraPadrões de apostas baixasGancho de loja reutilizávelPreferências críticas da UI

Não há um único vencedor. A correção correta depende se o FOUC é importante para o produto, quantos componentes consomem o valor armazenado e quantos dados você persiste. A maioria dos aplicativos se beneficia ao começar com useSyncExternalStore por seu contrato de hidratação explícito, (value, setter) API e sincronização cruzada integrada, migrando preferências específicas de alta visibilidade para cookies quando o flash se tornar inaceitável.

Armadilhas comuns e dicas de depuração

Não renderize condicionalmente árvores de componentes inteiras

Envolvendo componentes em if (typeof window !== 'undefined') causa incompatibilidades de formato de árvore. O servidor não renderiza nada; o cliente renderiza a subárvore completa. O React não consegue reconciliar um nó ausente com um presente, produzindo erros de hidratação que são mais difíceis de diagnosticar do que incompatibilidades de valores.

Use suprimirHydrationWarning com moderação

O suppressHydrationWarning prop é apropriado para conteúdo que é inerentemente diferente entre servidor e cliente, como carimbos de data/hora ou tokens CSRF. Você também pode aplicá-lo a or when browser extensions modify attributes outside React’s control. Using it to suppress localStorage-related mismatches masks real bugs and can lead to silent UI inconsistencies in production.

Verify with next build and next start

Always test hydration behavior by running next build && next start contra a construção de produção. Na produção, o React 18 registra um erro no console e tenta a recuperação do DOM, mas podem ocorrer falhas visuais. O modo de desenvolvimento adiciona verificações adicionais e rastreamentos de pilha, tornando mais fácil detectar problemas, mas também mais tolerante em alguns casos.

Embora a eliminação do FOUC seja um requisito difícil do produto, os cookies são o único padrão entre esses três que atende.

Recomendações

Comece com useSyncExternalStore para a maioria dos estados apoiados por localStorage; passar para cookies quando a preferência armazenada afetar a renderização acima da dobra e o FOUC for inaceitável. Para referência adicional, consulte o Documentação Next.js sobre erros de hidratação e o Documentação do React em useSyncExternalStore.



Source link