Persistência no navegador com localStorage e signals: o que ele resolve e onde ele termina
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.stringifyna escrita eJSON.parsena 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
localStoragepor origem. Ao estourar o limite, é lançado umQuotaExceededError, que deve ser tratado comtry...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 comsetouupdate;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.
Links
- Tutorial link: https://cursos.danielfaustino.com.br/#/curso/01-todo-localstorage
- View link: https://todo-localstorage-ten.vercel.app
- Repository link: https://gitlab.com/daniel.eng.dev/todo-localstorage