wandres.dev
PERSISTENCIA Y OFFLINE · hidratación y estado local

Persistir el estado

El estado reactivo vive en memoria y muere con la pestaña: cada recarga es una amnesia total, y todo lo que el usuario construyó durante la sesión se evapora sin dejar rastro. Persistir es darle a ese estado una memoria que sobrevive al ciclo de vida del documento, escribiéndolo en un sustrato durable del propio navegador. Esta lección enfrenta las dos puertas que el navegador ofrece para hacerlo —`localStorage`, síncrono, minúsculo y de solo texto, e `IndexedDB`, asíncrono, transaccional y capaz de albergar megabytes de datos estructurados— y defiende que la elección entre ambas no es un detalle de rendimiento sino una decisión de arquitectura con consecuencias en el hilo principal. Pero antes de resolver el cómo, la pregunta más difícil es el qué: qué fragmentos del estado merecen sobrevivir y cuáles deben morir con la sesión. Persistir de más es tan grave como no persistir nada; guardar el caché del servidor o un token de sesión en `localStorage` no es un atajo, es un error de modelado disfrazado de comodidad.

⏱ 16 min

Todo lo que has aprendido en este track vive en memoria: los signals, los stores, los átomos y las máquinas ocupan celdas de RAM que el navegador reclama en cuanto la pestaña se cierra o el usuario pulsa recargar. Esa volatilidad no es un defecto, es la naturaleza del estado reactivo, pero choca de frente con una expectativa humana básica: que las cosas sigan ahí cuando vuelvo. Persistir es tender un puente entre dos mundos con leyes distintas —la memoria efímera del proceso y el almacenamiento durable del dispositivo— y todo puente tiene un peaje. El navegador ofrece dos vías para cruzarlo, localStorage e IndexedDB, que no son alternativas intercambiables sino herramientas para problemas de escala opuesta. Elegir mal la vía cuesta caro: un localStorage abusado congela el primer render, y un IndexedDB usado para cuatro banderas booleanas es un cañón para matar moscas. Pero la decisión técnica es la fácil. La difícil, la que separa al ingeniero del copista, es trazar la frontera entre lo que merece sobrevivir y lo que debe morir con la sesión.

🎯 Al terminar esta lección sabrás
  • Distinguir localStorage de IndexedDB por su contrato real: sincronía, capacidad, tipo de dato y coste sobre el hilo principal.
  • Reconocer que la persistencia es una proyección del estado en memoria sobre un medio durable, no una copia literal.
  • Trazar la frontera del qué persistir: intención del usuario frente a caché, derivados y secretos.
  • Anticipar el coste oculto de la serialización y por qué no todo estado es serializable sin pérdida.

Dos sustratos, dos contratos

El navegador no ofrece un almacén, ofrece dos, y confundirlos es el primer error. localStorage es un mapa de cadenas a cadenas, síncrono y minúsculo: unos cinco a diez megabytes según el navegador, y solo texto. Su API es tan simple que seduce, pero esa simplicidad esconde una trampa que veremos abajo. IndexedDB, en cambio, es una base de datos transaccional, asíncrona y con capacidad de cientos de megabytes, capaz de guardar objetos estructurados, Blob y ArrayBuffer sin pasarlos por texto.

El eje que de verdad los separa no es el tamaño sino la sincronía. localStorage bloquea el hilo principal en cada lectura y cada escritura; IndexedDB cede el control y responde por promesa o por evento. Esa diferencia decide dónde puedes usar cada uno sin arruinar la fluidez de la interfaz.

// localStorage: sincrono, solo texto. Serializas tu mismo.
localStorage.setItem('tema', JSON.stringify({ modo: 'oscuro' }))
const tema = JSON.parse(localStorage.getItem('tema') ?? '{}')

// IndexedDB via un wrapper como idb-keyval: asincrono, estructurado.
import { get, set } from 'idb-keyval'
await set('borrador', { titulo: 'Nota', cuerpo: '...', adjuntos: [blob] })
const borrador = await get('borrador') // objeto real, sin JSON.parse

La regla de reparto es directa. Poca cosa que quepa en un puñado de kilobytes y necesites leer de golpe al arrancar —un tema, un idioma, una bandera— vive bien en localStorage. Datos que crecen, que son binarios o que no quieres serializar a mano —borradores largos, colas de trabajo, cachés offline— piden IndexedDB. Hay un tercer inquilino menor, sessionStorage, idéntico a localStorage pero atado a la vida de la pestaña: útil para estado que debe sobrevivir a una navegación pero no a un cierre.

Un matiz que sorprende a muchos es que el estado persistido es compartido entre pestañas del mismo origen. Dos pestañas de tu app leen y escriben el mismo localStorage, y una puede pisar lo que la otra guardó sin enterarse. El navegador ofrece el evento storage, que avisa a las demás pestañas cuando una escribe, precisamente para mantenerlas sincronizadas; ignorarlo produce el clásico bug de dos pestañas con estados divergentes que se sobrescriben al azar.

💾

localStorage

Síncrono, de solo texto, unos 5 MB. Lectura y escritura instantáneas pero bloqueantes. Ideal para preferencias pequeñas que necesitas leer en el primer render. Serializas y parseas tú mismo con JSON.

🗄️

IndexedDB

Asíncrono, transaccional, cientos de MB. Guarda objetos, Blob y ArrayBuffer mediante clonado estructurado. Ideal para datos grandes, binarios o colas offline. Úsalo con un wrapper como idb para no sufrir su API cruda.

🗂️

sessionStorage

Idéntico a localStorage en API y límites, pero su vida acaba con la pestaña. Útil para estado que debe sobrevivir a una navegación interna pero no a un cierre: un asistente de varios pasos, un formulario a medio rellenar.

🍪

Cookies

Diminutas y viajan en cada petición, lo que las hace pésimas para datos pero únicas en algo: el servidor las lee. Reserva las httpOnly para tokens y las normales para lo poco que el SSR necesite conocer antes de pintar.

La frontera del qué

Elegido el medio, queda la decisión que de verdad importa, y no es técnica: qué persistir. La tentación del principiante es serializar el store entero de un plumazo, y es un desastre silencioso. No todo el estado es igual, y persistir sin discriminar mezcla cosas que envejecen a ritmos incompatibles.

Persiste la intención del usuario: aquello que el usuario creó o eligió y esperaría reencontrar. Un borrador a medio escribir, los artículos del carrito, el tema y el idioma, la posición de un panel, los filtros de una tabla. Es estado que nace de una acción deliberada y cuya pérdida se vive como una traición.

No persistas el caché del servidor. Los datos que vinieron de una API tienen su fuente de verdad remota; guardarlos localmente crea una copia que caduca sin avisar y que el usuario verá obsoleta al volver. Refetch es más barato y más honesto que resucitar un caché rancio. Tampoco persistas estado derivado: si un total se calcula a partir del carrito, guardar el total es guardar una mentira en potencia; recalcúlalo. Y jamás persistas estado efímero de interfaz —un modal abierto, un hover, un foco— ni, sobre todo, secretos: un token de sesión en localStorage es legible por cualquier script y por tanto por cualquier ataque de inyección.

Un ejemplo hace tangible la partición. Imagina el estado de una tienda: el carrito y el tema son intención del usuario y se persisten; el catálogo y el perfil vienen del servidor y se refetchean; el total y el número de artículos son derivados y se recalculan; el token de sesión es secreto y viaja en una cookie httpOnly; el estado del menú desplegable es efímero y se descarta. Un solo store, cinco destinos distintos según el origen de cada dato.

const persistir = (s: Estado) => ({
  carrito: s.carrito,   // intencion del usuario -> se guarda
  tema: s.tema          // intencion del usuario -> se guarda
  // catalogo, perfil    -> del servidor -> refetch
  // total, numArticulos -> derivados -> recalcular
  // token               -> secreto -> cookie httpOnly
  // menuAbierto          -> efimero -> descartar
})
flowchart TD
E[fragmento de estado] --> Q{de donde nace}
Q -->|del usuario| P[persistir como intencion]
Q -->|del servidor| C[no persistir, refetch]
Q -->|se calcula| D[no persistir, recalcular]
Q -->|es un secreto| S[no persistir, cookie httpOnly]
P --> M{cuanto pesa}
M -->|kilobytes| L[localStorage]
M -->|megabytes o binario| I[IndexedDB]
⚠️
La trampa síncrona de localStorage

La API de localStorage es tan cómoda que invita a leerla en cualquier parte, pero cada acceso es una operación bloqueante sobre el hilo principal. Leer medio megabyte de JSON al arrancar puede añadir decenas de milisegundos antes del primer pintado, y hacerlo dentro de un bucle de render es un microcongelamiento garantizado. La regla: lee de localStorage una sola vez al inicio, hidrata tu store con ese valor y a partir de ahí trabaja en memoria, escribiendo de vuelta con parsimonia. Nunca lo uses como si fuera una variable reactiva.

Lo que se pierde al serializar

localStorage solo guarda texto, así que todo pasa por JSON.stringify, y ese viaje no es neutro. Un Date se convierte en cadena y vuelve como cadena, no como fecha. Un Map o un Set se serializa como un objeto vacío y se pierde entero. Una referencia circular lanza excepción. Y las funciones, los Symbol y los undefined desaparecen sin ruido. Serializar es proyectar una estructura rica sobre un plano pobre, y en esa proyección se pierde información.

IndexedDB sufre menos porque usa el algoritmo de clonado estructurado, que sí preserva Date, Map, Set, Blob y grafos con ciclos. Pero tampoco es total: no clona funciones ni prototipos, así que una instancia de clase vuelve como objeto plano, sin sus métodos. La lección de fondo es que el estado persistido nunca es el estado en memoria, sino una fotografía suya tomada con una cámara de rango limitado.

// El viaje por JSON pierde tipos. Reconstruyelos al hidratar.
const crudo = JSON.parse(localStorage.getItem('sesion') ?? '{}')
const sesion = {
  ...crudo,
  creadoEn: new Date(crudo.creadoEn),      // cadena -> Date
  etiquetas: new Set(crudo.etiquetas ?? []) // array -> Set
}

La regla que se destila es tratar la frontera de serialización como una aduana: define en un solo lugar cómo cada tipo especial cruza en ambos sentidos y no esparzas reconstrucciones de fechas por toda la app al leer. Un par de funciones serializar y deserializar centralizadas te ahorran el bug recurrente del campo que un día era fecha y hoy es cadena porque alguien olvidó rehidratarlo en un rincón olvidado.

Persistir es best-effort, no una garantía

Hay una creencia ingenua que conviene desmontar antes de confiar datos valiosos al navegador: que lo escrito en el disco se queda ahí para siempre. No es así. El almacenamiento del navegador es, por defecto, desechable. Cuando el dispositivo anda escaso de espacio, el navegador puede desalojar los datos de sitios que considera prescindibles, y localStorage tiene además un límite duro que, al superarse, lanza una excepción QuotaExceededError que la mayoría de las apps nunca captura.

Escribir, por tanto, puede fallar, y una estrategia de persistencia seria trata la escritura como una operación que puede lanzar, no como una asignación que siempre funciona. Envuelve cada escritura y degrada con elegancia si el cajón está lleno o cerrado: es preferible perder una preferencia a romper el flujo del usuario con una excepción sin capturar en mitad de una interacción.

function guardar(clave: string, valor: unknown) {
  try {
    localStorage.setItem(clave, JSON.stringify(valor))
  } catch (e) {
    // QuotaExceededError o modo privado: degrada, no revientes.
    console.warn('persistencia no disponible', e)
  }
}

Si tus datos son de verdad importantes —una cola offline, un borrador extenso—, puedes pedir al navegador que trate tu almacenamiento como persistente y no desechable con navigator.storage.persist(). No es una garantía absoluta, pero eleva tus datos a la categoría que el navegador desaloja en último lugar. La lección de fondo es de humildad: persistir es pedir prestado un cajón que no controlas del todo, no clavar los datos en piedra.

ℹ️
El modo privado miente sobre el almacenamiento

En navegación privada o incógnito muchos navegadores exponen localStorage e IndexedDB pero con capacidad casi nula o volátil: la escritura parece funcionar y luego desaparece, o falla de inmediato. Nunca asumas que la persistencia está disponible solo porque la API existe; compruébalo escribiendo y leyendo un valor centinela al arrancar, y ten siempre un camino de degradación para cuando no lo esté.

Persistir es decidir qué parte de tu estado es esencial

Volvamos al primer nivel de este track, donde separamos el estado esencial del accidental. La persistencia obliga a hacer esa distinción de forma irreversible y pública: cada fragmento que decides guardar es una declaración de que ese dato es esencial —irreemplazable, nacido de una intención que ningún cálculo puede reconstruir—, y cada fragmento que decides descartar es una declaración de que es accidental, derivable o recuperable de su fuente. El medio, localStorage o IndexedDB, es la parte trivial de la decisión; puedes cambiarlo en una tarde sin que nadie lo note. Lo que no puedes cambiar barato es haber trazado mal la frontera, porque persistir de más envenena tu aplicación con datos rancios que contradicen al servidor, y persistir de menos frustra al usuario que perdió lo que creyó a salvo. El ingeniero mediocre pregunta dónde guardo el estado; el que domina el oficio pregunta primero qué parte de este estado se ganó el derecho a sobrevivir a la muerte de la sesión, y solo entonces elige el cajón. Toda estrategia de persistencia sólida empieza siendo una ontología del estado, no una elección de API.

⚔️ Audita tu frontera de persistencia
  1. Lista todo el estado de una aplicación tuya y clasifica cada fragmento en cuatro cubos: intención del usuario, caché del servidor, derivado, y secreto o efímero.
  2. Para cada fragmento del primer cubo, decide su medio justificándolo por tamaño y sincronía: localStorage si son kilobytes, IndexedDB si crece o es binario.
  3. Busca en tu código actual cualquier token, contraseña o dato sensible que viva en localStorage y anota cómo lo moverías a una cookie httpOnly.
  4. Toma un objeto de tu estado con un Date, un Map o una instancia de clase y comprueba en consola qué queda de él tras JSON.stringify y JSON.parse.
  5. Mide con la pestaña de rendimiento cuánto tarda tu lectura inicial de localStorage y confirma que ocurre una sola vez y no dentro de un render.
  6. Escribe en una frase la regla que aplicarás de ahora en adelante para decidir si un dato merece persistirse, y guárdala como criterio de equipo.