wandres.dev
PERSISTENCIA · guardar y rehidratar

Qué persistir y qué no: el inventario antes del almacén

Persistir estado no es una función que se activa, es una decisión de propiedad sobre cada dato del store. Esta lección construye el criterio: clasificar el estado por quién es su dueño autoritativo —el usuario, el servidor, la sesión o el proveedor de identidad—, y derivar de ahí qué se guarda sin dudarlo, qué no debe guardarse nunca porque el servidor puede contradecirlo, y qué exige un tratamiento aparte porque su filtración tiene coste. Explica por qué la caché de datos remotos envenena la aplicación cuando sobrevive al cierre del navegador, por qué el almacenamiento web es legible por cualquier script de la página y por qué la lista de claves persistidas debe ser explícita y por inclusión, nunca por exclusión.

⏱ 18 min

Activar la persistencia es la línea de código más barata y la decisión más cara de este nivel. Una llamada envuelve el store, el navegador empieza a escribir en disco y todo parece funcionar mejor: el usuario vuelve y encuentra su tema oscuro, su carrito, su barra lateral plegada. Lo que no se ve en ese primer momento es que acabas de firmar un contrato con todas las versiones futuras de tu aplicación, porque a partir de ahora existe un estado que tú no controlas —lo escribió una versión anterior de tu código, en un navegador que no puedes inspeccionar, hace un tiempo que no puedes acotar— y que entrará en tu store como si fuera legítimo. Antes de estudiar mecánica conviene fijar el criterio, y el criterio no es técnico sino de propiedad: cada dato del store tiene un dueño autoritativo, y persistir solo tiene sentido cuando ese dueño es el cliente.

🎯 Al terminar esta lección sabrás
  • Clasificar el estado de una aplicación según quién es su fuente autoritativa de verdad.
  • Justificar por qué las preferencias del usuario son el caso natural de la persistencia.
  • Explicar el daño concreto que produce persistir la caché de datos del servidor.
  • Tratar credenciales y sesión con el modelo de amenaza correcto, no con el mismo interruptor.

La pregunta correcta no es qué cabe, sino quién manda

Todo dato que vive en un store tiene detrás una autoridad implícita: alguien cuya versión gana cuando hay conflicto. El tema visual lo decide el usuario en este dispositivo, y ninguna otra instancia puede contradecirlo. El precio de un producto lo decide el servidor, y cualquier copia local es una fotografía con fecha de caducidad desconocida. La posición del cursor en un formulario a medio llenar la decide la sesión, y fuera de ella no significa nada. La identidad del usuario la decide un proveedor externo que puede revocarla sin avisarte.

De esa clasificación sale el criterio entero de este nivel, y se enuncia en una frase: persiste aquello cuya autoridad es el cliente; no persistas aquello que otro puede contradecir. Cuando guardas un dato del que no eres dueño, no estás optimizando una carga, estás creando una segunda fuente de verdad sin ningún mecanismo de reconciliación, que es la definición exacta de una incoherencia esperando a manifestarse.

🎨

Preferencias: dueño el usuario

Tema, idioma, densidad de la tabla, columnas visibles, panel plegado, unidades. Nadie fuera de este dispositivo tiene una opinión mejor. Caso natural de persistencia.

🛰️

Datos remotos: dueño el servidor

Listas, detalles, precios, permisos, contadores. Tu copia es una caché, y una caché que sobrevive a la sesión sin política de frescura se convierte en desinformación.

💨

Efímero: dueño la sesión

Modales abiertos, estados de carga, errores de red, foco, desplazamiento. Persistirlo resucita situaciones que el usuario ya había cerrado.

🔑

Credenciales: dueño la identidad

Tokens de acceso y refresco. No es estado de interfaz: es una llave. Se gobierna con un modelo de amenaza, no con una opción de configuración.

Hay una categoría intermedia que merece nombre propio porque genera casi todas las discusiones de equipo: el borrador. El texto que el usuario lleva escrito en un formulario largo no es preferencia ni dato del servidor, es trabajo suyo todavía no entregado. Su dueño es el usuario, pero a diferencia del tema, tiene una vida corta y una expectativa de privacidad mayor. Persistirlo suele ser correcto y humano —perder veinte minutos de escritura por un cierre accidental es una crueldad evitable—, con dos condiciones: que se guarde en un almacén ligado a la sesión cuando el contenido sea sensible, y que se borre explícitamente al enviarse, porque un borrador que sobrevive a su envío reaparece como un fantasma en el siguiente formulario.

La caché del servidor: por qué guardarla envenena

La tentación de persistir datos remotos es comprensible: la pantalla aparece instantánea al volver, sin espera y sin esqueleto de carga. El problema no es el rendimiento, es que una caché es un contrato con tres cláusulas —frescura, invalidación y desalojo— y el almacenamiento del navegador no implementa ninguna. Cuando escribes la respuesta de una petición en disco, guardas los datos pero no guardas lo que los hacía confiables: la marca temporal que permitía saber si estaban viejos, las etiquetas que permitían invalidarlos y la identidad del usuario para el que se pidieron.

⚠️
Tres daños concretos, no hipotéticos

El primero es la mentira duradera: el usuario vuelve dos semanas después y ve precios, plazos o permisos de la visita anterior, sin ninguna señal de que está mirando el pasado. El segundo es la fuga entre cuentas: si el almacén está atado al origen y no al usuario, quien inicie sesión después en el mismo navegador puede rehidratar datos que no le pertenecen. El tercero es el crecimiento silencioso: las cachés crecen y el almacenamiento web tiene un límite de unos pocos megabytes por origen, de modo que un día una escritura falla y, si no la tratas, deja el estado guardado a medias.

Hay una excepción que confirma la regla y conviene nombrarla para que nadie la use como coartada: el modo sin conexión. Una aplicación que debe funcionar sin red sí persiste datos del servidor, y lo hace deliberadamente, pero entonces no está guardando una caché sino construyendo una réplica, que es una cosa distinta y mucho más cara. Una réplica exige un registro de cambios locales pendientes de enviar, una estrategia de resolución cuando el servidor y el cliente divergen, y una interfaz que comunique al usuario que está viendo datos locales. Si tu proyecto no ha aceptado ese coste, no estás haciendo modo sin conexión: estás haciendo una caché sin reglas y llamándola de otra manera.

Las librerías de datos remotos que sí ofrecen persistencia lo hacen precisamente añadiendo las cláusulas que faltan, y observar qué añaden es la mejor forma de entender qué te falta cuando persistes a mano: una edad máxima tras la cual lo guardado se descarta entero, un identificador de versión del esquema que invalida todo lo anterior al desplegar, una clave que incorpora la identidad del usuario y una política de qué consultas son elegibles. Sin esas cuatro piezas, persistir la caché no es una optimización: es aplazar un error hasta que sea difícil de reproducir.

// Lo minimo que convierte una cache persistida en algo defendible.
type CachePersistida = {
  version: string        // sube al cambiar la forma de los datos
  usuario: string        // la clave del almacen incluye a quien pertenece
  guardadoEn: number     // marca temporal para calcular la edad
  datos: unknown
}

const EDAD_MAXIMA = 1000 * 60 * 60 * 24   // un dia

function esUtilizable(c: CachePersistida, actual: string): boolean {
  if (c.version !== VERSION_ESQUEMA) return false
  if (c.usuario !== actual) return false
  return Date.now() - c.guardadoEn < EDAD_MAXIMA
}

Tokens: una llave no es una preferencia

El almacenamiento web es un objeto legible y escribible por cualquier script que se ejecute en la página, incluidos los que no escribiste tú: una dependencia comprometida, un fragmento de analítica, un anuncio, una extensión. No hay permisos, no hay ámbito por script y no hay auditoría. Esa es toda la explicación de por qué guardar un token de acceso allí es un problema: no porque el almacén sea inseguro en sí mismo, sino porque una única ejecución de código ajeno en tu origen basta para exfiltrar la sesión completa del usuario, y la exfiltración no deja rastro en tu servidor.

La alternativa estándar traslada la custodia al navegador en un lugar donde tu propio JavaScript no llega: una cookie marcada como inaccesible desde scripts, restringida al mismo sitio y enviada solo por canal seguro. El código de la aplicación deja de conocer el token, lo cual es exactamente el objetivo, y a cambio hay que aceptar dos consecuencias: que la protección frente a peticiones falsificadas desde otros sitios pasa a ser tu responsabilidad, y que el estado de sesión ya no puede leerse del store sino consultarse al servidor.

💡
Si el token debe vivir en el cliente, que viva en memoria

Cuando la arquitectura impide usar cookies gestionadas por el servidor, la posición defendible es guardar el token de acceso solo en memoria, con vida corta, y renovarlo silenciosamente al cargar la aplicación contra un token de refresco que sí custodia el navegador en una cookie inaccesible. Se paga una petición al arrancar y se gana que ningún script pueda leer una credencial de un almacén persistente. Lo que nunca debe hacerse es persistir el token de refresco en el almacenamiento web: es la llave de mayor duración del sistema, y su filtración vale más que la del token de acceso.

flowchart TD
D[dato del store] --> Q{quien es el dueno}
Q -->|el usuario| P[persistir en almacenamiento local]
Q -->|la sesion| S[persistir en almacenamiento de sesion o nada]
Q -->|el servidor| C{tiene edad y version}
C -->|no| N[no persistir]
C -->|si| L[persistir con caducidad y clave por usuario]
Q -->|la identidad| T[cookie inaccesible o memoria]
style P fill:#a6e3a1,color:#11111b
style N fill:#f38ba8,color:#11111b
style T fill:#f9e2af,color:#11111b

El inventario explícito: por inclusión, nunca por exclusión

El comportamiento por defecto de casi todas las herramientas de persistencia es guardar el estado entero, y ese defecto es el origen de la mayoría de los incidentes de este nivel. No porque sea una mala elección de diseño, sino porque convierte la lista de datos persistidos en algo que crece sin decisión: cada campo nuevo que alguien añade al store queda persistido automáticamente, sin que nadie se pregunte si debía estarlo. La corrección es invertir la política y declarar una lista por inclusión, de modo que añadir un dato al almacén sea un acto deliberado y visible en la revisión de código.

// Lista blanca: lo que no aparece aqui no se guarda, y anadirlo se revisa.
const PERSISTIBLE = {
  tema: true,
  idioma: true,
  densidadTabla: true,
  columnasVisibles: true,
} as const

const seleccionar = (s: Estado) =>
  Object.fromEntries(
    Object.entries(s).filter(([k]) => k in PERSISTIBLE),
  )

La lista por inclusión resuelve además dos problemas que aparecen siempre y que la exclusión no puede resolver. El primero es que las funciones del store no son serializables: al convertirse a texto desaparecen sin error, de modo que lo guardado tiene la forma del estado pero sin ninguna de sus acciones, y quien lea el almacén verá un objeto engañosamente parecido al original. El segundo es que hay tipos que sobreviven al guardado con otra identidad —una fecha vuelve como texto, un conjunto y un mapa vuelven como objeto vacío, un valor infinito vuelve como nulo— y si no los declaras, la rehidratación introduce datos con el tipo equivocado sin que nadie lo advierta hasta que un método falla.

ℹ️
Lo que el texto plano no sabe representar

Cualquier estructura que no tenga equivalente directo en el formato de intercambio necesita una pareja de funciones que la conviertan al guardar y la reconstruyan al leer. Es trabajo mecánico y aburrido, y por eso se olvida: se descubre meses después, cuando alguien invoca un método sobre lo que creía un conjunto y resulta ser un objeto sin nada dentro. La regla práctica es persistir solo formas primitivas —texto, número, booleano, listas y objetos planos— y reconstruir las estructuras ricas en el momento de leer, no en el de guardar.

// Convertir al guardar y reconstruir al leer: el par que evita tipos falsos.
const alGuardar = (s: Estado) => ({
  ...s,
  favoritos: [...s.favoritos],              // un conjunto se guarda como lista
  ultimaVisita: s.ultimaVisita.toISOString(),
})

const alLeer = (g: ReturnType<typeof alGuardar>): Estado => ({
  ...g,
  favoritos: new Set(g.favoritos),
  ultimaVisita: new Date(g.ultimaVisita),
})

Ese inventario tiene además un efecto que ninguna herramienta te da: obliga a escribir, junto a cada clave, por qué está ahí. Y esa justificación de una línea es lo que permite, dos años después, decidir si una clave puede retirarse sin miedo. Un almacén sin inventario es un desván: nadie recuerda qué hay dentro y por eso nadie se atreve a tirar nada.

Persistir es exportar estado fuera del ciclo de vida del programa, y todo lo demás se sigue de ahí

La razón de que la persistencia genere una clase entera de errores desproporcionada a su tamaño en líneas de código es que rompe el supuesto más profundo bajo el que razonamos sobre programas: que el estado nace con la ejecución y muere con ella, y que por tanto el código que lo produce y el código que lo consume son el mismo código. Cuando escribes en el almacén del navegador, ese supuesto deja de valer y aparece una frontera que no está en ninguna firma de función: al otro lado hay un dato que produjo una versión de tu programa que ya no existe, para un usuario que no puedes consultar, en un momento que no puedes acotar, y que va a entrar en tu store con la confianza que reservas a lo que tú mismo has construido. Persistir es, en sentido estricto, definir un formato de intercambio entre versiones de tu aplicación; y un formato de intercambio no es una decisión de implementación sino una interfaz pública, con todo lo que eso implica: exige un contrato explícito de qué campos incluye, una versión que permita interpretarlo, una política de compatibilidad y una autoridad que resuelva los conflictos. La mayoría de los equipos no ven esa interfaz porque el almacenamiento web tiene aspecto de variable global y no de protocolo, y esa ilusión de familiaridad es lo que hace que se le apliquen decisiones que jamás se tomarían al diseñar una API. De ahí el criterio de propiedad que ordena esta lección: no es una heurística de higiene, es la consecuencia lógica de que un dato con dueño externo, guardado al otro lado de esa frontera, se convierte en una afirmación sobre el mundo que tu programa no puede seguir sosteniendo. Y de ahí también la asimetría moral con las credenciales: cuando lo que exportas fuera del ciclo de vida no es una preferencia sino una llave, el error deja de costar una interfaz incoherente y pasa a costar la cuenta de alguien.

⚔️ Levanta el inventario de tu aplicación
  1. Enumera todas las claves de primer nivel de tu store y asigna a cada una un dueño: usuario, sesión, servidor o identidad. Escribe la tabla, aunque tenga treinta filas.
  2. Marca las que hoy se persisten. Si usas el comportamiento por defecto, la respuesta es todas, incluidas las que no sabías que existían.
  3. Abre las herramientas del navegador, inspecciona el almacén de tu propia aplicación y lee lo que hay dentro. Anota lo que te sorprenda encontrar.
  4. Convierte la política a lista por inclusión y justifica cada clave en un comentario de una línea. Comprueba qué desaparece del almacén.
  5. Localiza cualquier dato con dueño servidor que estuvieras guardando y decide entre retirarlo o dotarlo de versión, edad máxima y clave por usuario.
  6. Revisa dónde vive tu token de acceso. Si está en el almacenamiento web, escribe el plan concreto de migración a cookie inaccesible o a memoria con renovación silenciosa.