LocalStorage e SessionStorage

LocalStorage e SessionStorage

Fechar a aba não precisa significar perder tudo. LocalStorage e SessionStorage guardam dados no próprio navegador, com uma API de quatro métodos e uma regra que pega todo mundo: só existe string ali dentro, e um objeto salvo sem JSON.stringify vira "[object Object]" sem aviso nenhum.
Javascript

• • 21 min de leitura

Imagine que o usuário passou dez minutos preenchendo uma lista de tarefas na sua aplicação. Ele fecha a aba acidentalmente e, ao abrir de novo, tudo sumiu. Frustração garantida.

Ou imagine um e-commerce onde o carrinho de compras esvazia toda vez que o usuário navega para outra página. Inaceitável.

É para resolver problemas como esses que existem o LocalStorage e o SessionStorage — duas APIs nativas do navegador que permitem salvar dados diretamente no dispositivo do usuário, sem precisar de um servidor ou banco de dados.

A diferença fundamental

Ambos funcionam de forma idêntica em termos de API, mas diferem em persistência:

  LocalStorage SessionStorage
Duração Permanente — fica até ser deletado manualmente Temporário — some quando a aba/janela fecha
Escopo Compartilhado entre todas as abas do mesmo domínio Exclusivo da aba atual
Capacidade ~5MB por domínio ~5MB por aba
Uso típico Preferências, login, carrinho Dados de sessão, formulários temporários

A API — simples e direta

Os quatro métodos que você vai usar o tempo todo:

// Salvar
localStorage.setItem("chave", "valor");

// Ler
const valor = localStorage.getItem("chave");

// Remover um item
localStorage.removeItem("chave");

// Limpar tudo
localStorage.clear();

// Ver quantos itens há
console.log(localStorage.length);

// Iterar sobre todas as chaves
for (let i = 0; i < localStorage.length; i++) {
  const chave = localStorage.key(i);
  const valor = localStorage.getItem(chave);
  console.log(`${chave}: ${valor}`);
}

O sessionStorage tem exatamente a mesma API — basta trocar localStorage por sessionStorage.

O detalhe mais importante — tudo é string

O LocalStorage só armazena strings. Se você tentar salvar um número, boolean, array ou objeto, ele será convertido para string automaticamente — e de forma silenciosa, causando bugs difíceis de rastrear:

// ❌ Comportamento inesperado
localStorage.setItem("ativo", true);
const ativo = localStorage.getItem("ativo");
console.log(ativo);        // "true" — string, não boolean!
console.log(ativo === true); // false — bug!

localStorage.setItem("quantidade", 42);
const qtd = localStorage.getItem("quantidade");
console.log(qtd + 1); // "421" — concatenação de string, não soma!

// Objeto vira string inútil
localStorage.setItem("usuario", { nome: "Ana" });
console.log(localStorage.getItem("usuario")); // "[object Object]"

A solução — JSON.stringify e JSON.parse

Sempre que salvar dados que não sejam strings simples, converta para JSON:

// ✅ Salvando objetos e arrays corretamente

// Salvar
const usuario = { nome: "Ana", idade: 28, premium: true };
localStorage.setItem("usuario", JSON.stringify(usuario));

// Ler — sempre parse ao recuperar
const dadosSalvos = localStorage.getItem("usuario");
const usuarioRecuperado = JSON.parse(dadosSalvos);

console.log(usuarioRecuperado.nome);    // "Ana"
console.log(usuarioRecuperado.premium); // true — boolean de verdade!

// Arrays funcionam da mesma forma
const tarefas = ["Estudar", "Praticar", "Construir"];
localStorage.setItem("tarefas", JSON.stringify(tarefas));

const tarefasRecuperadas = JSON.parse(localStorage.getItem("tarefas"));
console.log(tarefasRecuperadas); // ["Estudar", "Praticar", "Construir"]

Tratamento de erros ao ler

O JSON.parse lança um erro se o valor salvo não for um JSON válido — o que pode acontecer se os dados foram corrompidos ou salvos incorretamente:

function lerDoStorage(chave, valorPadrao = null) {
  try {
    const item = localStorage.getItem(chave);
    if (item === null) return valorPadrao;
    return JSON.parse(item);
  } catch (erro) {
    console.error(`Erro ao ler "${chave}" do localStorage:`, erro);
    return valorPadrao;
  }
}

function salvarNoStorage(chave, valor) {
  try {
    localStorage.setItem(chave, JSON.stringify(valor));
    return true;
  } catch (erro) {
    // Pode falhar se o storage estiver cheio
    console.error(`Erro ao salvar "${chave}" no localStorage:`, erro);
    return false;
  }
}

// Uso limpo e seguro
const preferencias = lerDoStorage("preferencias", { tema: "claro", idioma: "pt-BR" });
salvarNoStorage("preferencias", { ...preferencias, tema: "escuro" });

Encapsular as operações de storage em funções utilitárias é uma ótima prática — você trata o erro uma vez e usa com segurança em todo o código.

Criando um helper de storage reutilizável

const storage = {
  get(chave, padrao = null) {
    try {
      const item = localStorage.getItem(chave);
      return item !== null ? JSON.parse(item) : padrao;
    } catch {
      return padrao;
    }
  },

  set(chave, valor) {
    try {
      localStorage.setItem(chave, JSON.stringify(valor));
      return true;
    } catch {
      return false;
    }
  },

  remove(chave) {
    localStorage.removeItem(chave);
  },

  clear() {
    localStorage.clear();
  },

  existe(chave) {
    return localStorage.getItem(chave) !== null;
  },
};

// Uso
storage.set("usuario", { nome: "Pedro", plano: "pro" });
const usuario = storage.get("usuario");
console.log(usuario.nome); // "Pedro"

storage.set("visitas", (storage.get("visitas", 0)) + 1);
console.log(storage.get("visitas")); // 1, 2, 3...

Evento storage — sincronizando abas

Uma funcionalidade pouco conhecida: o evento storage dispara em outras abas do mesmo domínio quando o localStorage muda. Isso permite sincronizar estado entre abas:

// Em qualquer aba — detecta mudanças feitas por outras abas
window.addEventListener("storage", (evento) => {
  console.log("Chave alterada:", evento.key);
  console.log("Valor antigo:", evento.oldValue);
  console.log("Novo valor:", evento.newValue);
  console.log("URL de origem:", evento.url);

  // Exemplo: sincronizar tema entre abas
  if (evento.key === "tema") {
    aplicarTema(JSON.parse(evento.newValue));
  }
});

Exemplo completo — To-Do List com persistência

Vamos evoluir a To-Do List do artigo Criando e Removendo Elementos Dinamicamente adicionando persistência com LocalStorage:

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <title>To-Do com Persistência</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: 'Segoe UI', sans-serif;
      background: #f0f2f5;
      display: flex;
      justify-content: center;
      padding: 2rem 1rem;
    }

    .app {
      background: white;
      border-radius: 12px;
      padding: 2rem;
      width: 100%;
      max-width: 500px;
      box-shadow: 0 4px 24px rgba(0,0,0,.08);
    }

    h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #1a1a2e; }

    .topo {
      display: flex;
      gap: .5rem;
      margin-bottom: 1rem;
    }

    input[type="text"] {
      flex: 1;
      padding: .65rem 1rem;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
      font-size: 1rem;
    }

    input[type="text"]:focus { outline: none; border-color: #5c6bc0; }

    .btn-add {
      padding: .65rem 1.2rem;
      background: #5c6bc0;
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-weight: 700;
      font-size: 1rem;
    }

    .filtros {
      display: flex;
      gap: .5rem;
      margin-bottom: 1rem;
    }

    .filtro {
      padding: .35rem .85rem;
      border: 2px solid #e0e0e0;
      background: white;
      border-radius: 999px;
      cursor: pointer;
      font-size: .85rem;
      font-weight: 600;
      color: #666;
      transition: all .2s;
    }

    .filtro.ativo {
      background: #5c6bc0;
      border-color: #5c6bc0;
      color: white;
    }

    .info {
      display: flex;
      justify-content: space-between;
      font-size: .85rem;
      color: #888;
      margin-bottom: 1rem;
    }

    .btn-acao {
      background: none;
      border: none;
      color: #e53935;
      cursor: pointer;
      font-size: .85rem;
      text-decoration: underline;
    }

    ul { list-style: none; }

    .tarefa {
      display: flex;
      align-items: center;
      gap: .75rem;
      padding: .75rem 1rem;
      border-radius: 8px;
      margin-bottom: .5rem;
      background: #f8f9ff;
      border: 1px solid #e8eaf6;
      animation: entrar .2s ease;
    }

    @keyframes entrar {
      from { opacity: 0; transform: translateY(-6px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .tarefa.concluida .texto {
      text-decoration: line-through;
      color: #aaa;
    }

    input[type="checkbox"] {
      width: 18px;
      height: 18px;
      cursor: pointer;
      accent-color: #5c6bc0;
    }

    .texto { flex: 1; }

    .data {
      font-size: .75rem;
      color: #bbb;
    }

    .btn-del {
      background: none;
      border: none;
      color: #ddd;
      cursor: pointer;
      font-size: 1.2rem;
      transition: color .2s;
    }

    .btn-del:hover { color: #e53935; }

    .vazio {
      text-align: center;
      color: #ccc;
      padding: 2rem;
    }

    .badge-storage {
      font-size: .75rem;
      background: #e8eaf6;
      color: #5c6bc0;
      padding: .2rem .6rem;
      border-radius: 999px;
      margin-top: 1.5rem;
      text-align: center;
    }
  </style>
</head>
<body>
<div class="app">
  <h1>📝 Tarefas</h1>

  <div class="topo">
    <input type="text" id="input" placeholder="Nova tarefa...">
    <button class="btn-add" id="btn-add">+</button>
  </div>

  <div class="filtros">
    <button class="filtro ativo" data-filtro="todas">Todas</button>
    <button class="filtro" data-filtro="pendentes">Pendentes</button>
    <button class="filtro" data-filtro="concluidas">Concluídas</button>
  </div>

  <div class="info">
    <span id="contador"></span>
    <button class="btn-acao" id="btn-limpar">Limpar concluídas</button>
  </div>

  <ul id="lista"></ul>
  <p class="badge-storage" id="badge">💾 Dados salvos no LocalStorage</p>
</div>

<script>
  // ── Helper de storage ──────────────────────────────
  const storage = {
    get: (chave, padrao = null) => {
      try {
        const item = localStorage.getItem(chave);
        return item !== null ? JSON.parse(item) : padrao;
      } catch { return padrao; }
    },
    set: (chave, valor) => {
      try {
        localStorage.setItem(chave, JSON.stringify(valor));
        return true;
      } catch { return false; }
    },
  };

  // ── Estado ─────────────────────────────────────────
  let tarefas = storage.get("tarefas-app", []);
  let filtroAtivo = storage.get("filtro-app", "todas");
  let proximoId = storage.get("proximo-id", 1);

  // ── Referências ────────────────────────────────────
  const input = document.querySelector("#input");
  const btnAdd = document.querySelector("#btn-add");
  const lista = document.querySelector("#lista");
  const contador = document.querySelector("#contador");
  const btnLimpar = document.querySelector("#btn-limpar");
  const badge = document.querySelector("#badge");
  const filtros = document.querySelectorAll(".filtro");

  // ── Persistência ───────────────────────────────────
  function salvarEstado() {
    storage.set("tarefas-app", tarefas);
    storage.set("filtro-app", filtroAtivo);
    storage.set("proximo-id", proximoId);
    atualizarBadge();
  }

  function atualizarBadge() {
    const bytes = JSON.stringify(tarefas).length;
    const kb = (bytes / 1024).toFixed(2);
    badge.textContent = `💾 ${tarefas.length} tarefa(s) salva(s) no LocalStorage · ${kb} KB`;
  }

  // ── Lógica ─────────────────────────────────────────
  function tarefasFiltradas() {
    switch (filtroAtivo) {
      case "pendentes":  return tarefas.filter(t => !t.concluida);
      case "concluidas": return tarefas.filter(t => t.concluida);
      default:           return tarefas;
    }
  }

  function adicionar() {
    const texto = input.value.trim();
    if (!texto) { input.focus(); return; }

    tarefas.push({
      id: proximoId++,
      texto,
      concluida: false,
      criadaEm: new Date().toLocaleDateString("pt-BR"),
    });

    input.value = "";
    input.focus();
    salvarEstado();
    renderizar();
  }

  function alternar(id) {
    tarefas = tarefas.map(t =>
      t.id === id ? { ...t, concluida: !t.concluida } : t
    );
    salvarEstado();
    renderizar();
  }

  function remover(id) {
    tarefas = tarefas.filter(t => t.id !== id);
    salvarEstado();
    renderizar();
  }

  function limparConcluidas() {
    tarefas = tarefas.filter(t => !t.concluida);
    salvarEstado();
    renderizar();
  }

  // ── Renderização ───────────────────────────────────
  function renderizar() {
    const visiveis = tarefasFiltradas();
    lista.innerHTML = "";

    if (visiveis.length === 0) {
      lista.innerHTML = `<li class="vazio">
        ${filtroAtivo === "todas"
          ? "Nenhuma tarefa ainda. Adicione uma acima!"
          : `Nenhuma tarefa ${filtroAtivo} no momento.`}
      </li>`;
    } else {
      const fragment = document.createDocumentFragment();

      visiveis.forEach(tarefa => {
        const li = document.createElement("li");
        li.classList.add("tarefa");
        if (tarefa.concluida) li.classList.add("concluida");

        const check = document.createElement("input");
        check.type = "checkbox";
        check.checked = tarefa.concluida;
        check.addEventListener("change", () => alternar(tarefa.id));

        const span = document.createElement("span");
        span.classList.add("texto");
        span.textContent = tarefa.texto;

        const data = document.createElement("span");
        data.classList.add("data");
        data.textContent = tarefa.criadaEm;

        const btnDel = document.createElement("button");
        btnDel.classList.add("btn-del");
        btnDel.textContent = "×";
        btnDel.title = "Remover";
        btnDel.addEventListener("click", () => remover(tarefa.id));

        li.append(check, span, data, btnDel);
        fragment.appendChild(li);
      });

      lista.appendChild(fragment);
    }

    // Atualiza contador
    const total = tarefas.length;
    const concluidas = tarefas.filter(t => t.concluida).length;
    contador.textContent = `${total - concluidas} pendente(s) · ${concluidas} concluída(s)`;

    // Atualiza filtros ativos
    filtros.forEach(btn => {
      btn.classList.toggle("ativo", btn.dataset.filtro === filtroAtivo);
    });
  }

  // ── Eventos ────────────────────────────────────────
  btnAdd.addEventListener("click", adicionar);

  input.addEventListener("keydown", (e) => {
    if (e.key === "Enter") adicionar();
  });

  btnLimpar.addEventListener("click", limparConcluidas);

  filtros.forEach(btn => {
    btn.addEventListener("click", () => {
      filtroAtivo = btn.dataset.filtro;
      salvarEstado();
      renderizar();
    });
  });

  // Sincroniza se outra aba alterar o storage
  window.addEventListener("storage", (e) => {
    if (e.key === "tarefas-app") {
      tarefas = JSON.parse(e.newValue) || [];
      renderizar();
    }
  });

  // ── Inicialização ──────────────────────────────────
  renderizar();
  input.focus();
</script>
</body>
</html>

Abra esta página, adicione tarefas, feche e reabra — seus dados estarão lá. Abra em duas abas diferentes e observe a sincronização em tempo real.

Limitações e alternativas

O LocalStorage é poderoso para casos simples, mas tem limitações importantes:

Limitações:
- Apenas strings (resolvido com JSON)
- ~5MB por domínio (não use para imagens ou dados grandes)
- Síncrono — pode bloquear a thread em grandes volumes
- Não funciona em modo privado em alguns browsers
- Não é criptografado — nunca salve senhas ou tokens sensíveis

Para casos mais avançados, considere:

// IndexedDB — banco de dados completo no navegador
// Assíncrono, suporta objetos complexos, muito mais espaço
// Complexo de usar diretamente — use a biblioteca idb

// Cookies — persistência controlada, enviados ao servidor
// Úteis para autenticação

// Cache API — parte do Service Worker
// Ideal para PWAs e funcionamento offline

Para a maioria dos casos do dia a dia, LocalStorage é suficiente e prático.

Boas práticas

// ✅ 1. Use prefixo nas chaves para evitar conflitos
localStorage.setItem("minhaApp:usuario", JSON.stringify(usuario));
localStorage.setItem("minhaApp:configuracoes", JSON.stringify(config));

// ✅ 2. Sempre tenha valor padrão ao ler
const tema = storage.get("tema", "claro");

// ✅ 3. Nunca salve informações sensíveis
// ❌ Jamais faça isso:
localStorage.setItem("senha", "minhasenha123");
localStorage.setItem("token", "eyJhbGciOiJIUzI1NiJ9...");

// ✅ 4. Trate erros — o storage pode estar desabilitado
// (modo privado, políticas de segurança, storage cheio)

// ✅ 5. Versione seus dados para migrações futuras
const VERSAO = "2";
const versaoSalva = localStorage.getItem("minhaApp:versao");
if (versaoSalva !== VERSAO) {
  // clear() apagaria o domínio inteiro — inclusive as chaves de outra
  // aplicação hospedada na mesma origem. Remova só o que é seu:
  Object.keys(localStorage)
    .filter(chave => chave.startsWith("minhaApp:"))
    .forEach(chave => localStorage.removeItem(chave));
  localStorage.setItem("minhaApp:versao", VERSAO);
}

Tarefa para você

Construa um bloco de notas persistente com:

  1. Campo de texto grande (<textarea>) onde o usuário escreve
  2. Salvar automaticamente no LocalStorage a cada 2 segundos (se houve mudança)
  3. Exibir data e hora do último salvamento
  4. Botão "Nova nota" que limpa o campo (com confirmação)
  5. Contador de caracteres em tempo real
  6. Ao carregar a página, restaurar o último conteúdo salvo
// Dica: use setTimeout/clearTimeout para o salvamento automático
let timerSalvar;
textarea.addEventListener("input", () => {
  clearTimeout(timerSalvar);
  timerSalvar = setTimeout(() => {
    salvar();
  }, 2000);
});
Ver solução — o bloco de notas persistente, com autosave que não perde texto
// ---- index.html
// <textarea id="nota" rows="18" placeholder="Escreva..."></textarea>
// <div class="barra">
//   <span id="contador">0 caractere(s)</span>
//   <span id="status" role="status">—</span>
//   <button id="btn-nova">Nova nota</button>
// </div>

// ---- notas.js
const nota = document.querySelector("#nota");
const contador = document.querySelector("#contador");
const status = document.querySelector("#status");
const btnNova = document.querySelector("#btn-nova");

const CHAVE = "bloco-de-notas:v1"; // versão na chave: muda o formato, muda a chave
const ESPERA = 2000;

let timerSalvar;
let ultimoSalvo = "";

// ---------------------------------------------------------------
// LocalStorage guarda STRING — o resto é serialização
// ---------------------------------------------------------------
// Guardar { texto, salvoEm } junto permite mostrar a data do último
// salvamento depois de recarregar. Só o texto perderia essa informação.
function salvar() {
  const texto = nota.value;

  // Nada mudou desde o último salvamento: não escreve. LocalStorage é
  // síncrono e bloqueia a thread principal — escrever à toa trava a
  // digitação em notas grandes.
  if (texto === ultimoSalvo) return;

  const registro = { texto, salvoEm: new Date().toISOString() };

  try {
    localStorage.setItem(CHAVE, JSON.stringify(registro));
    ultimoSalvo = texto;
    mostrarStatus(new Date(registro.salvoEm));
  } catch (erro) {
    // QuotaExceededError: a cota (~5 MB) estourou, ou o navegador está
    // em modo privado com armazenamento bloqueado. Falhar calado aqui
    // é como o usuário perde três horas de texto.
    status.textContent = "⚠️ Não foi possível salvar (armazenamento cheio ou bloqueado).";
    console.error(erro);
  }
}

function carregar() {
  const bruto = localStorage.getItem(CHAVE);
  if (!bruto) return;

  try {
    // JSON.parse estoura com dado corrompido — e dado no LocalStorage
    // fica lá para sempre, inclusive o que uma versão antiga gravou
    // em outro formato.
    const registro = JSON.parse(bruto);

    nota.value = registro.texto ?? "";
    ultimoSalvo = nota.value;

    if (registro.salvoEm) mostrarStatus(new Date(registro.salvoEm));
  } catch {
    console.warn("Nota corrompida no LocalStorage — recomeçando.");
    localStorage.removeItem(CHAVE);
  }
}

function mostrarStatus(data) {
  status.textContent = `Salvo às ${data.toLocaleTimeString("pt-BR")} de ${data.toLocaleDateString("pt-BR")}`;
}

function atualizarContador() {
  const total = nota.value.length;
  const palavras = nota.value.trim() ? nota.value.trim().split(/\s+/).length : 0;
  contador.textContent = `${total} caractere(s) · ${palavras} palavra(s)`;
}

// ---------------------------------------------------------------
// 2 e 5 — autosave com debounce, e contador em tempo real
// ---------------------------------------------------------------
nota.addEventListener("input", () => {
  atualizarContador();       // imediato: é resposta visual
  status.textContent = "Digitando...";

  // Debounce: cada tecla cancela o timer anterior. O salvamento
  // acontece 2s depois da ÚLTIMA tecla, não 2s depois da primeira.
  clearTimeout(timerSalvar);
  timerSalvar = setTimeout(salvar, ESPERA);
});

// ---------------------------------------------------------------
// 4 — nova nota, com confirmação
// ---------------------------------------------------------------
btnNova.addEventListener("click", () => {
  if (nota.value.trim() === "") return;

  if (!confirm("Isto apaga a nota atual. Continuar?")) return;

  clearTimeout(timerSalvar); // senão o timer pendente regravaria o texto antigo
  nota.value = "";
  localStorage.removeItem(CHAVE);
  ultimoSalvo = "";

  atualizarContador();
  status.textContent = "Nota nova.";
  nota.focus();
});

// ---------------------------------------------------------------
// 6 — restaurar ao carregar
// ---------------------------------------------------------------
carregar();
atualizarContador();

// ---------------------------------------------------------------
// A rede de segurança do debounce: fechar a aba antes dos 2 segundos
// ---------------------------------------------------------------
// Com só o setTimeout, quem digita e fecha a aba em seguida perde o
// que escreveu. `visibilitychange` é o gancho confiável para isso —
// `beforeunload` não dispara em todo fechamento no celular.
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") salvar();
});

// ---------------------------------------------------------------
// Duas abas abertas na mesma nota
// ---------------------------------------------------------------
// O evento `storage` dispara nas OUTRAS abas quando esta grava. Sem
// isso, a segunda aba continua com o texto velho e, ao salvar, apaga
// o que a primeira escreveu.
window.addEventListener("storage", (evento) => {
  if (evento.key !== CHAVE || !evento.newValue) return;

  const registro = JSON.parse(evento.newValue);

  // Só sobrescreve se o usuário não estiver digitando NESTA aba.
  if (document.activeElement !== nota) {
    nota.value = registro.texto;
    ultimoSalvo = registro.texto;
    atualizarContador();
    mostrarStatus(new Date(registro.salvoEm));
  } else {
    status.textContent = "⚠️ Esta nota foi alterada em outra aba.";
  }
});

// ---------------------------------------------------------------
// O detalhe que morde: LocalStorage não guarda objeto
// ---------------------------------------------------------------
localStorage.setItem("teste", { a: 1 });
console.log(localStorage.getItem("teste")); // "[object Object]"

// O setItem converte com String(), e String({}) é "[object Object]".
// Não dá erro, não avisa — o dado simplesmente vira lixo. Sempre
// JSON.stringify na ida e JSON.parse na volta.
localStorage.setItem("teste", JSON.stringify({ a: 1 }));
console.log(JSON.parse(localStorage.getItem("teste")).a); // 1

localStorage só guarda string, e converte o que não é sem reclamar: um objeto vira "[object Object]" e o dado se perde em silêncio. Some a isso o limite de ~5 MB, que estoura em QuotaExceededError — e é por isso que o setItem aqui está dentro de um try.

O localStorage guarda texto e nada mais — daí todo objeto passar por JSON.stringify na ida e JSON.parse na volta, e daí uma data voltar como string. Os outros limites aparecem depois: cerca de 5 MB, acesso síncrono que trava a página quando o volume cresce, e nenhuma proteção contra quem abre o DevTools. Serve para preferência, rascunho e estado de interface; não serve para token de sessão nem para dado que precise ser confiável.

Fontes e leituras recomendadas

Exercícios

Exercício 1

A chave "x" nunca foi gravada. Qual das quatro linhas lança erro?

console.log(localStorage.getItem("x"));             // A
console.log(JSON.parse(localStorage.getItem("x"))); // B

localStorage.setItem("y", undefined);
console.log(localStorage.getItem("y"));             // C
console.log(JSON.parse(localStorage.getItem("y"))); // D
Ver resposta

✓ Resposta: Só a D. A é null — chave inexistente devolve null, nunca undefined, e é por isso que o helper do artigo testa item !== null. B também é null, e aqui está a sutileza: o JSON.parse converte o argumento para string antes de analisar, então recebe "null", que é JSON perfeitamente válido, e devolve null sem erro nenhum. O try/catch em volta parece estar protegendo a leitura de chave ausente, mas nunca dispara nesse caso. C imprime a string "undefined": o setItem converteu o valor com String(), exatamente como faz com objeto ao produzir "[object Object]". E aí D estoura com SyntaxError: Unexpected token 'u' — "undefined" não é JSON. A lição prática é que gravar undefined é pior do que não gravar: cria um valor que passa no teste !== null e quebra na leitura seguinte, numa parte do código bem distante de onde o erro foi cometido.

Exercício 2

Você abre a página numa aba só, com o DevTools aberto, e roda as duas linhas. O que aparece no console?

window.addEventListener("storage", (e) => {
  console.log("mudou:", e.key, "→", e.newValue);
});

localStorage.setItem("tema", JSON.stringify("escuro"));
Ver resposta

✓ Resposta: Nada. O evento storage é notificação para as outras abas ou janelas da mesma origem — a aba que escreveu não é avisada, pela razão simples de que ela já sabe. Esse é o motivo por trás de quase todo relato de "implementei a sincronização entre abas e não funciona": o teste foi feito numa aba só. Para ver o evento, abra a mesma página em duas abas e escreva numa delas. Três detalhes que acompanham: o evento também não dispara quando o valor gravado é idêntico ao que já estava lá; ele traz oldValue e newValue, ambos null no caso de um clear(), que chega com key igual a null; e o sessionStorage não dispara entre abas de jeito nenhum, porque cada aba tem o seu, isolado. Quando é preciso comunicar abas com mais controle, a ferramenta certa é o BroadcastChannel.

Exercício 3

A To-Do do artigo está aberta em duas abas. Na aba A o usuário cria três tarefas; a aba B recebe o evento storage e se atualiza. Em seguida o usuário cria uma tarefa na aba B. O que acontece?

let tarefas   = storage.get("tarefas-app", []);
let proximoId = storage.get("proximo-id", 1);

window.addEventListener("storage", (e) => {
  if (e.key === "tarefas-app") {
    tarefas = JSON.parse(e.newValue) || [];
    renderizar();
  }
});
Ver resposta

✓ Resposta: A aba B cria uma tarefa com um id que já existe. O listener sincroniza tarefas, mas não toca em proximoId, que continua valendo o que B leu ao abrir — se as duas abas abriram com a lista vazia, B ainda acha que o próximo id é 1, enquanto A já gastou 1, 2 e 3. O estrago aparece depois, porque toda a lógica identifica tarefa por id: alternar(1) usa map e marca as duas tarefas de id 1, e remover(1) usa filter e apaga as duas de uma vez. O sintoma — "cliquei numa tarefa e outra mudou junto" — não sugere em nada a causa, que está na inicialização de uma variável. Há três saídas, em ordem crescente de robustez: sincronizar proximoId junto no listener; deduzir o próximo a partir dos dados, com Math.max(0, ...tarefas.map(t => t.id)) + 1, o que dispensa guardar o contador; ou abandonar o contador sequencial e usar crypto.randomUUID(), que não depende de ninguém saber o que os outros fizeram.

Exercício 4

Na aba 1 você grava um rascunho no sessionStorage. Em quais das quatro situações ele ainda está lá?

sessionStorage.setItem("rascunho", "meio texto");

// 1. o usuário aperta F5 na aba 1
// 2. o usuário abre o mesmo site numa aba nova
// 3. o usuário fecha a aba 1 e abre o site de novo
// 4. o usuário fecha o navegador inteiro e reabre
Ver resposta

✓ Resposta: Sobrevive só na 1. O sessionStorage pertence à aba, e recarregar não encerra a aba — inclusive é esse o uso que o justifica: guardar o que não deve vazar para outros contextos, como um formulário de várias etapas, sem sujar o armazenamento permanente. Na 2 a aba nova começa com o seu próprio armazenamento vazio, ainda que seja o mesmo site no mesmo navegador; a exceção curiosa é a aba duplicada pelo menu do navegador, que herda uma cópia do que havia no momento da duplicação. Nas situações 3 e 4 o dado já se foi, porque a aba deixou de existir. Trocando as quatro para localStorage, o rascunho estaria presente em todas — ele só some por ação explícita, pela limpeza de dados do navegador ou pelo próprio navegador liberando espaço.

Exercício 5

Esta era a receita de versionamento do artigo, antes da correção. O que ela apaga quando a versão muda?

const VERSAO = "2";

if (localStorage.getItem("minhaApp:versao") !== VERSAO) {
  localStorage.clear();
  localStorage.setItem("minhaApp:versao", VERSAO);
}
Ver resposta

✓ Resposta: Apaga tudo o que existe naquela origem, e não apenas as chaves prefixadas com minhaApp: — o que faz o trecho contradizer, três linhas depois, o conselho de usar prefixo justamente para conviver com outros. O armazenamento é compartilhado por protocolo, domínio e porta, então basta um segundo aplicativo servido do mesmo endereço, ou uma página de administração em outra rota, para que o clear() derrube o estado alheio. Em desenvolvimento o efeito é ainda mais fácil de encontrar, porque tudo mora em localhost. A limpeza correta percorre as chaves e remove só as suas: Object.keys(localStorage).filter(c => c.startsWith("minhaApp:")).forEach(c => localStorage.removeItem(c)). Vale acrescentar que apagar raramente é a melhor migração: quando o formato muda, converter o dado antigo para o novo preserva o trabalho do usuário, e apagar deve ficar para o caso em que a conversão é impossível.

Comentários

Mais em Javascript

Tratamento de erros em requisições HTTP
Tratamento de erros em requisições HTTP

Código que só funciona quando tudo dá certo não está pronto para produção…

Arquitetura de Software: SOLID, Clean Architecture e DDD
Arquitetura de Software: SOLID, Clean Architecture e DDD

Se houver uma frase para levar daqui, é a regra das dependências: elas apontam…

Projeto Final: Revisão Completa e Aplicação de Produção
Projeto Final: Revisão Completa e Aplicação de Produção

O fim da série reúne tudo numa aplicação de produção: React com rotas e…