12k
All articles

Como Persistir Estado no Local Storage com React

Persista o estado do React em localStorage com um hook reutilizável: useState preguiçoso, JSON try/catch, proteção SSR e sincronização entre abas.

OpenReplay Team
OpenReplay Team
Como Persistir Estado no Local Storage com React

Para persistir o estado do React no localStorage, inicialize o useState a partir do storage dentro de sua função inicializadora e grave o valor de volta sempre que ele mudar — em seguida, envolva o JSON em try/catch e proteja contra renderização no lado do servidor.

Todo aplicativo React eventualmente desenvolve um desses, geralmente para um seletor de tema ou uma barra lateral que deve permanecer recolhida. A versão de três linhas leva cinco minutos para ser escrita e depois silenciosamente custa uma tarde inteira mais tarde. Essa versão ingênua funciona para um contador em uma única aba, mas falha de três maneiras previsíveis: trava com dados corrompidos, lança window is not defined no Next.js e fica desatualizada entre abas. Este artigo constrói um hook useLocalStorage subindo a escada da correção, corrigindo cada modo de falha em sequência, e termina com um hook pronto para uso que você pode colar em um projeto React 18 ou 19.

O localStorage é um armazenamento síncrono, de mesma origem, somente para strings, de aproximadamente 5MB por origem, documentado na MDN Web Storage API. Uma regra antes de qualquer código: nunca armazene tokens de autenticação ou dados pessoais (PII) nele. Ele é legível por qualquer JavaScript na página e não é criptografado.

Principais Conclusões

  • Leia o localStorage dentro do inicializador do useState para que a busca seja executada uma vez na montagem, em vez de exibir o valor padrão primeiro por meio de um useEffect.
  • Como o localStorage armazena apenas strings, persista com JSON.stringify na escrita e JSON.parse na leitura, envolvidos em try/catch para que um valor corrompido não possa travar o componente.
  • No servidor não existe window, portanto ler o storage durante a primeira renderização lança window is not defined no Next.js e no Remix. Renderize o padrão no servidor e sincronize com o valor persistido após a montagem.
  • O evento storage do navegador dispara apenas em outras abas, nunca naquela que gravou o valor, portanto os listeners da mesma aba precisam de um evento despachado manualmente.
  • O useSyncExternalStore, adicionado no React 18, é a forma oficialmente suportada de assinar um componente a um store mutável externo como o localStorage.

O padrão ingênuo do React com localStorage

O ponto de partida é um useState com inicialização preguiçosa combinado com um effect de escrita. No React, leia o localStorage dentro da função inicializadora do useState para que a busca seja executada uma vez na montagem, em vez de lê-lo em um useEffect que exibiria o valor padrão primeiro.

import { useState, useEffect } from 'react';

function ThemeToggle() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
      Theme: {theme}
    </button>
  );
}

Passar uma função para o useState (e não useState(localStorage.getItem(...))) é importante: o inicializador preguiçoso é executado apenas na primeira renderização, evitando assim acessos ao localStorage em cada re-renderização. Ler no inicializador em vez de em um useEffect separado também significa que o valor correto está presente na primeira pintura, sem o flash de padrão-depois-persistido.

Serialize com segurança usando JSON e try/catch

A versão ingênua lida apenas com strings. Como o localStorage armazena apenas strings, persista estados que não sejam strings com JSON.stringify na escrita e JSON.parse na leitura, e envolva o parse em try/catch para que um único valor corrompido ou legado não possa travar o componente. Um modo de falha comum em produção é uma mudança de schema ou um valor parcialmente gravado deixando JSON inválido em uma chave; sem a proteção, o JSON.parse lança uma exceção na montagem e derruba o componente.

function readJSON<T>(key: string, fallback: T): T {
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback; // valor corrompido ou legado → retorna ao padrão
  }
}

O bloco catch retorna o padrão em vez de propagar o erro, o que faz a diferença entre uma chave inválida redefinindo uma preferência e uma chave inválida deixando a página em branco.

Como construir um hook useLocalStorage reutilizável?

Encapsule o padrão em um hook que espelha o useState para que seja um substituto direto. Para manter a paridade com o useState, o setter do seu useLocalStorage deve aceitar uma atualização funcional, de modo que setValue(prev => prev + 1) funcione da mesma forma que com o estado nativo. É a lacuna ergonômica que a maioria das versões artesanais ignora.

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => readJSON(key, initialValue));

  const set = useCallback(
    (next: T | ((prev: T) => T)) => {
      setValue(prev => {
        const resolved = next instanceof Function ? next(prev) : next;
        localStorage.setItem(key, JSON.stringify(resolved));
        return resolved;
      });
    },
    [key],
  );

  return [value, set] as const;
}

A verificação next instanceof Function é o que preserva a ergonomia do useState. Esta versão está correta no cliente, mas ainda lê o localStorage durante a renderização, o que quebra no momento em que você a renderiza no servidor.

O problema com SSR: “window is not defined” e incompatibilidade de hidratação

No servidor não existe window ou localStorage, portanto ler o storage durante a primeira renderização lança window is not defined no Next.js e no Remix. Proteja com typeof window === 'undefined' e leia o valor persistido após a montagem.

Há um segundo bug mais sutil mesmo depois de parar o crash. Uma incompatibilidade de hidratação ocorre porque o servidor renderiza seu estado padrão enquanto o cliente já possui o valor armazenado; a primeira renderização do cliente no React deve corresponder ao HTML do servidor, portanto, se você ler o localStorage no inicializador durante a hidratação, a marcação diverge. A solução é renderizar o padrão no servidor e depois sincronizar com o valor persistido em um effect após a hidratação.

const IS_SERVER = typeof window === 'undefined';

function useLocalStorage<T>(key: string, initialValue: T, initializeWithValue = true) {
  const readValue = () => (IS_SERVER ? initialValue : readJSON(key, initialValue));

  const [value, setValue] = useState<T>(() =>
    initializeWithValue ? readValue() : initialValue,
  );

  useEffect(() => {
    setValue(readValue()); // sincroniza do storage após a montagem
  }, [key]);
  // ...setter como antes
}

O flag initializeWithValue espelha o switch no usehooks-ts useLocalStorage: defina-o como false para SSR para que o hook retorne o padrão no servidor e sincronize após a hidratação. Essa classe de bug é quase invisível em um carregamento limpo no localhost. Reproduzir uma sessão real de produção é muitas vezes a forma como o flash de hidratação (o tema padrão sendo pintado por um frame antes de o valor persistido assumir) realmente se torna visível, pois depende de tempo e ambiente em vez de ser reproduzível sob demanda.

Sincronização entre abas e a abordagem moderna com useSyncExternalStore

O estado persistido deve permanecer consistente quando um usuário tem duas abas abertas. O evento storage do navegador, descrito na MDN’s Window: storage event, dispara apenas em outras abas e documentos, nunca na aba que gravou o valor. A sincronização entre abas, portanto, precisa de um listener de storage, e os listeners da mesma aba precisam de um evento personalizado despachado manualmente.

Para código novo, existe uma primitiva mais limpa do que useState + effects. O useSyncExternalStore foi introduzido no React 18 como a forma oficial de assinar um componente a um store mutável externo. Os componentes normalmente leem de props, estado e contexto, mas ocasionalmente um precisa ler um valor que vive fora do React e muda ao longo do tempo, incluindo APIs do navegador que mantêm um valor mutável e emitem eventos quando ele muda. A própria referência do React para o hook recomenda o estado nativo quando possível e o reserva principalmente para integração com código não-React existente. O localStorage se qualifica, e é por isso que as bibliotecas mantidas o adotaram para leituras seguras para concorrência e corretas entre abas.

function useLocalStorageValue(key: string, initial: string) {
  const subscribe = (cb: () => void) => {
    window.addEventListener('storage', cb);
    return () => window.removeEventListener('storage', cb);
  };
  return useSyncExternalStore(
    subscribe,
    () => localStorage.getItem(key) ?? initial,
    () => initial, // snapshot do servidor
  );
}

Você deve criar seu próprio useLocalStorage ou usar uma biblioteca?

Crie o seu próprio quando precisar de um único valor primitivo no cliente. Recorra a uma biblioteca mantida quando precisar de casos extremos de serialização, SSR e sincronização entre abas tratados em conjunto. Ambas as opções abaixo funcionam no React 18 e 19. A linha de lançamento atual é o React 19.2, lançado em 1º de outubro de 2025, com os lançamentos de patch 19.2.x desde então listados no changelog do React.

OpçãoIdeal paraTratamento de SSRObservações
Hook artesanalPrimitivos pontuais, controle totalGuard typeof window + effect pós-montagemVocê é responsável pelos casos extremos
usehooks-tsHook pronto com removeValueinitializeWithValue: falseConstruído sobre useState + eventos, não useSyncExternalStore
use-local-storage-stateCorreção entre abas e concorrênciaConstruído sobre useSyncExternalStoreAmplamente utilizado; o mantenedor observa que componentes em hidratação podem renderizar duas vezes

Aqui está o hook artesanal completo, correto no React 18 e 19, com inicialização preguiçosa, JSON com try/catch, guard para SSR, atualizações funcionais, removeValue e eventos entre abas e na mesma aba:

import { useCallback, useEffect, useState } from 'react';

const IS_SERVER = typeof window === 'undefined';

type Options<T> = {
  serializer?: (value: T) => string;
  deserializer?: (value: string) => T;
  initializeWithValue?: boolean; // defina false para SSR
};

export function useLocalStorage<T>(
  key: string,
  initialValue: T,
  options: Options<T> = {},
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  const { initializeWithValue = true } = options;
  const serialize = options.serializer ?? JSON.stringify;
  const deserialize = options.deserializer ?? ((v: string) => JSON.parse(v) as T);

  const readValue = useCallback((): T => {
    if (IS_SERVER) return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? deserialize(raw) : initialValue;
    } catch {
      return initialValue;
    }
  }, [key, initialValue, deserialize]);

  const [storedValue, setStoredValue] = useState<T>(() =>
    initializeWithValue ? readValue() : initialValue,
  );

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        const next = value instanceof Function ? value(readValue()) : value;
        window.localStorage.setItem(key, serialize(next));
        setStoredValue(next);
        window.dispatchEvent(new StorageEvent('local-storage', { key }));
      } catch {
        /* cota excedida ou modo privado — ignorar */
      }
    },
    [key, readValue, serialize],
  );

  const removeValue = useCallback(() => {
    window.localStorage.removeItem(key);
    setStoredValue(initialValue);
    window.dispatchEvent(new StorageEvent('local-storage', { key }));
  }, [key, initialValue]);

  // Sincroniza do storage após a montagem (corrige hidratação SSR) e na mudança de chave.
  useEffect(() => {
    setStoredValue(readValue());
  }, [key]); // eslint-disable-line react-hooks/exhaustive-deps

  // Listeners entre abas ('storage') + na mesma aba ('local-storage').
  useEffect(() => {
    const onChange = (event: Event) => {
      const e = event as StorageEvent;
      if (e.key && e.key !== key) return;
      setStoredValue(readValue());
    };
    window.addEventListener('storage', onChange);
    window.addEventListener('local-storage', onChange);
    return () => {
      window.removeEventListener('storage', onChange);
      window.removeEventListener('local-storage', onChange);
    };
  }, [key, readValue]);

  return [storedValue, setValue, removeValue];
}

Passe um initialValue estável (um primitivo ou um objeto memoizado) para que as dependências do effect não se agitem a cada renderização.

Persistir o estado do React é uma escada, não uma linha única: comece com um useState com inicialização preguiçosa e um effect de escrita, adicione JSON com try/catch, proteja para SSR e então conecte os eventos entre abas. Adicione o hook acima em um arquivo compartilhado hooks/, substitua o useState por ele para o trecho de estado que precisa sobreviver a uma atualização, e recorra ao useSyncExternalStore ou a uma biblioteca mantida no momento em que a correção de concorrência entre abas começar a importar.

Perguntas Frequentes

Qual é a diferença entre localStorage e sessionStorage para persistir o estado do React?

Ambos são armazenamentos síncronos, de mesma origem, somente para strings, de aproximadamente 5MB, mas diferem em tempo de vida. O localStorage persiste indefinidamente até ser explicitamente limpo, portanto o estado sobrevive a uma atualização, ao fechamento de aba e à reinicialização do navegador. O sessionStorage é limitado a uma única sessão de aba e é apagado quando essa aba é fechada, e não é compartilhado entre abas. Use localStorage para preferências que devem sobreviver à sessão e sessionStorage para estado transitório por aba.

Por que não devo usar Redux Persist ou um store global para persistir um único trecho de estado?

Recorrer a um store global como o Redux Persist para salvar um único valor adiciona um store, middleware e configuração de serialização para um estado que um hook local já trata. Um hook useLocalStorage mantém o valor colocado junto ao componente que o possui e espelha a ergonomia do useState, incluindo atualizações funcionais. O Redux Persist justifica seu peso quando você já executa um store Redux e precisa de reidratação de slices inteiros, não para um seletor de tema ou um único campo de formulário.

O que acontece quando o localStorage está cheio ou desativado no modo de navegação privada?

Gravar no localStorage lança um QuotaExceededError quando a cota de origem de aproximadamente 5MB é excedida, e alguns navegadores lançam em qualquer escrita no modo privado ou incógnito porque a cota é definida como zero. Um setItem sem proteção trava o componente, razão pela qual o setter em um hook robusto envolve as escritas em try/catch. As leituras também devem retornar ao padrão para que um store bloqueado ou cheio degrade para estado em memória em vez de quebrar a renderização.

O useSyncExternalStore substitui completamente o padrão useState mais useEffect com localStorage?

Não em todos os casos. O useSyncExternalStore, adicionado no React 18, é a forma segura para concorrência de assinar um componente a um store mutável externo e é a escolha certa quando a correção entre abas e a renderização concorrente importam. A própria documentação do React recomenda o estado nativo quando possível e reserva o hook para integração com stores não-React. Para um único primitivo somente no cliente, um useState com inicialização preguiçosa e um effect de escrita permanece mais simples e correto; adote o useSyncExternalStore quando as abas precisarem permanecer sincronizadas.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.