daniel.faustino

Início / Artigos

Persistência no navegador com localStorage e signals: o que ele resolve e onde ele termina

  • Angular
  • Signals
  • JavaScript
  • FrontEnd

Todo desenvolvedor front-end passa por esta cena: a lista de tarefas funciona, o usuário adiciona dez itens, aperta F5 e tudo desaparece. O estado vivia só na memória da página. Antes de falar de servidor, banco de dados ou autenticação, existe uma solução que cabe em algumas linhas e vem pronta no navegador: o localStorage.

Este artigo usa um app de tarefas em Angular como caso de uso para responder três perguntas: como o localStorage funciona, como combiná-lo com os signals do Angular de forma que salvar seja uma consequência automática do estado, e quais limites dizem que está na hora de trocar de solução.

O problema: estado em memória é efêmero

Uma aplicação de página única guarda o estado em variáveis. Ao recarregar a página, o navegador descarta o código em execução e começa de novo. Para sobreviver a isso, o dado precisa morar fora da memória da página: no servidor ou no próprio navegador.

O servidor resolve o problema completo (vários dispositivos, vários usuários), mas exige uma API, um banco e um sistema de contas. Para um primeiro projeto, ou para dados que pertencem só àquele navegador, o armazenamento local é uma etapa intermediária honesta.

O que é o localStorage

O localStorage faz parte da Web Storage API. Segundo a documentação da MDN, ele é particionado apenas pela origem: todos os documentos com a mesma origem enxergam a mesma área de armazenamento. Diferente do sessionStorage, cujos dados morrem quando a aba é fechada, o localStorage persiste mesmo depois que o navegador é fechado e reaberto. Em janelas privadas, ele se comporta como o sessionStorage, e os dados são apagados ao fechar.

Algumas características pesam no desenho da solução:

  • Só guarda texto. Objetos precisam ser serializados, normalmente com JSON.stringify na escrita e JSON.parse na leitura.
  • É síncrono. Leituras e escritas bloqueiam o JavaScript até terminar, o que pode prejudicar o desempenho com muitos dados. A MDN recomenda alternativas assíncronas, como o IndexedDB, para grandes volumes.
  • Tem cota. Os navegadores guardam até 5 MiB de localStorage por origem. Ao estourar o limite, é lançado um QuotaExceededError, que deve ser tratado com try...catch.
  • Tem evento entre abas. O evento storage é disparado quando a área de armazenamento muda, o que permite a outras abas do mesmo site reagirem.

Signals: estado que sabe quando mudou

No Angular, um signal é um invólucro de valor que avisa os interessados quando o valor muda; lê-se chamando-o como função. A documentação oficial destaca três peças:

  • signal() cria o estado, atualizado com set ou update;
  • computed() deriva valores de outros signals, de forma preguiçosa e memorizada, recalculando só quando uma dependência muda;
  • effect() executa efeitos colaterais quando signals mudam, para integrar com APIs que não são reativas.

O localStorage é exatamente uma API não reativa. Essa é a oportunidade: se a gravação for um effect que lê a lista de tarefas, salvar passa a ser consequência do estado e não uma chamada que cada método precisa lembrar de fazer.

Na prática: o serviço do app de tarefas

Vamos construir o app de tarefas em um serviço único que guarda o estado e a persistência. O núcleo é pequeno:

private readonly _todos = signal<Todo[]>(this.load());
readonly todos = this._todos.asReadonly();

constructor() {
  effect(() => this.save(this._todos()));
}

O estado é privado e gravável; o mundo externo recebe uma versão somente leitura. Como o effect lê _todos, toda alteração (adicionar, marcar, remover) é gravada sem que nenhum método chame save. É impossível esquecer de persistir em um caminho novo.

As atualizações são imutáveis: update recebe uma função que devolve uma nova lista. Isso mantém o fluxo de dados previsível e é o que faz o computed recalcular os totais e os filtros no momento certo.

Persistência defensiva

O que diferencia um exemplo de didática de um código confiável é o tratamento dos caminhos de falha:

private load(): Todo[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    const data: unknown = JSON.parse(raw);
    return Array.isArray(data) ? (data as Todo[]) : [];
  } catch {
    return [];
  }
}

Três decisões estão aí. O dado lido é tratado como unknown, porque o armazenamento pode conter qualquer coisa, inclusive um JSON corrompido ou editado à mão. O try/catch cobre tanto o JSON inválido quanto o armazenamento indisponível. E, no pior caso, o app começa com a lista vazia em vez de quebrar. A escrita segue a mesma lógica: se a cota estourar ou o modo privado bloquear, o aplicativo continua funcionando em memória.

A chave de armazenamento tem versão (todo-app:v1). Quando o formato dos dados mudar no futuro, a versão permite migrar ou descartar o formato antigo sem ambiguidade.

Várias abas, um estado

Um usuário pode abrir o app em duas abas. Sem cuidado, cada uma sobrescreveria a outra. O serviço escuta o evento storage e, ao detectar mudança na própria chave, recarrega a lista. Como o evento é dirigido às outras abas do mesmo site, a aba que fez a alteração não entra em loop com ela mesma.

Antes do código, a especificação

Há também uma prática de processo que ajuda aqui: escrever a especificação antes de implementar. Com uma abordagem orientada a especificação (como a de ferramentas do tipo OpenSpec), cada comportamento vira um requisito com cenários verificáveis. Um exemplo é o requisito de que o app inicie com a lista vazia, sem erro visível, quando o conteúdo salvo for inválido. Cada cenário vira um teste do serviço ou um roteiro de verificação (como abrir duas abas), e a persistência defensiva deixa de ser uma intenção e passa a ser algo que a suíte verifica a cada alteração.

Vantagens, limites e quando não usar

Vantagens. Não exige servidor, conta ou configuração. A API é pequena e disponível em todos os navegadores. Combinado com signals, o código fica curto e sem chamadas de salvamento espalhadas.

Limites.

  • Cota de cerca de 5 MiB por origem, o que serve para uma lista de tarefas, mas não para arquivos ou grandes coleções.
  • Operações síncronas, que podem travar a interface com volumes grandes.
  • Dados só naquele navegador: nada é compartilhado entre dispositivos ou pessoas.
  • Leitura por qualquer script da mesma origem. Por isso, nunca guarde dados sensíveis, como senhas ou tokens.
  • Limpeza pelo usuário ou pelo navegador. A MDN descreve o modo best-effort: o navegador pode descartar os dados sob pressão de armazenamento, e o usuário pode apagá-los nas configurações.

Quando migrar. Dados que precisam existir em mais de um dispositivo, que pertencem a um usuário autenticado ou que crescem além de alguns megabytes pedem uma API. Para volumes maiores no próprio navegador, o IndexedDB é a alternativa citada pela MDN.

Conclusão

O localStorage com signals é uma combinação pequena e eficiente: o estado é a fonte da verdade, a gravação é um efeito automático e as falhas são tratadas com defesa em vez de otimismo. Conhecer os limites dessa solução é parte do aprendizado, porque são eles que justificam o próximo passo natural: levar os dados para uma API REST.

Um bom exercício é reproduzir o serviço descrito aqui, escrever os testes de JSON corrompido e de cota excedida, e só então decidir se o seu caso de uso ainda cabe no navegador.

Fontes consultadas

← Todos os artigos