LocalStorage e SessionStorage

[70] 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 Referências

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

Revisão + Projeto Final: SPA Completa
Revisão + Projeto Final: SPA Completa

As cinco peças do módulo em uma aplicação só: rotas com layout e proteção…

A evolução das requisições: de XMLHttpRequest ao Fetch
A evolução das requisições: de XMLHttpRequest ao Fetch

O fetch não caiu do céu. Antes dele foram quinze anos de XMLHttpRequest —…

React Router: navegação em SPAs
React Router: navegação em SPAs

Numa SPA o servidor entrega o HTML uma vez e o JavaScript decide o que exibir…