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

Persistencia selectiva

La lección que cierra el nivel corrige la ilusión con la que muchos empiezan: que persistir es volcar el store entero y olvidarse. No lo es. Persistir bien es un acto de exclusión deliberada —una lista blanca de lo que merece disco— y de mantenimiento perpetuo de un formato que sobrevivirá a tu propio código. La primera mitad es la persistencia selectiva: hay estado que no solo es innecesario guardar sino peligroso, y encabezan la lista los tokens y datos sensibles, porque `localStorage` es legible por cualquier script y convierte una inyección en un robo de cuenta; les siguen el caché del servidor, que caduca, y los derivados, que mienten. La segunda mitad es el versionado: en cuanto despliegas persistencia, el objeto que escribes en el disco del usuario se convierte en un formato de datos público que millones de navegadores conservarán, y una versión futura de tu app encontrará la forma de ayer. Sin un número de versión y una cadena de migraciones que transforme lo viejo en lo nuevo, cada cambio de esquema es una bomba de relojería en los dispositivos de quienes ya tenían datos guardados. Persistir, se descubre al final, es publicar un contrato que te obligas a mantener para siempre.

⏱ 17 min

Llegas al final del nivel con las herramientas para persistir, hidratar, sobrevivir al servidor y funcionar sin red, y justo aquí aparece la tentación más peligrosa: usarlas para todo. Es cómodo escribir una línea que serializa el store completo y creer el trabajo hecho, pero esa línea siembra dos problemas que estallarán meses después, cuando ya nadie recuerde haberla escrito. El primero es de seguridad y alcance: no todo estado debe tocar el disco, y algunos fragmentos —los tokens, los datos personales, el caché que caduca— hacen daño precisamente por estar guardados. El segundo es de tiempo: el instante en que despliegas persistencia, el objeto que escribes deja de ser tuyo y pasa a vivir en los discos de tus usuarios, congelado con la forma que tu código tenía ese día. Tu código seguirá evolucionando; el objeto guardado, no. Y cuando la versión de mañana lea la forma de hoy, o peor, la de hace un año, o falla con elegancia o corrompe la experiencia. La persistencia madura no es, por tanto, guardar más: es guardar menos y con un contrato de versión que te permita cambiar sin romper a quien ya confió sus datos a tu formato.

🎯 Al terminar esta lección sabrás
  • Aplicar la persistencia selectiva como una lista blanca por inclusión y nunca como un volcado total del store.
  • Justificar por qué tokens, datos sensibles, caché del servidor y derivados jamás deben persistirse.
  • Tratar el estado guardado como un formato de datos público que sobrevive y evoluciona con el código.
  • Diseñar un versionado con cadena de migraciones y un descarte seguro como último recurso.

Lista blanca, nunca volcado total

La regla de oro de qué persistir es la misma que la de los permisos: denegar por defecto y permitir por excepción. En lugar de guardar el store entero y excluir lo problemático —una lista negra que olvidará el próximo campo peligroso que añadas—, declara explícitamente el puñado de campos que merecen disco. Los middlewares lo llaman partialize: una función que recibe el estado completo y devuelve solo la porción a persistir.

// Lista blanca: solo lo que el usuario esperaria reencontrar.
const persistir = (estado: Estado) => ({
  tema: estado.tema,
  idioma: estado.idioma,
  borrador: estado.borrador,
  // NO: token, usuario.datosSensibles, cacheServidor, totalesDerivados
})

Lo que queda fuera no es arbitrario. Los tokens y secretos encabezan la exclusión por una razón que va más allá de la higiene, y la desarrollamos abajo. El caché del servidor se excluye porque su verdad vive remota y guardarlo local solo crea copias rancias que contradicen a la API al volver. Los datos derivados se excluyen porque son recalculables y persistirlos es guardar una respuesta que puede quedar desalineada con la pregunta. Y el estado efímero de interfaz se excluye porque nadie echa de menos un modal que estaba abierto.

⚠️
Un token en localStorage es una toma de cuenta esperando ocurrir

Merece detenerse en por qué los secretos son la exclusión más crítica. localStorage es accesible por cualquier JavaScript que se ejecute en tu página, incluido el que un atacante logre inyectar mediante XSS. Si tu token de sesión vive ahí, una sola vulnerabilidad de inyección —una dependencia comprometida, un fragmento de HTML sin sanear— deja de ser un problema molesto y se convierte en el robo total de la sesión del usuario, porque el script malicioso solo tiene que leer una clave. Los tokens pertenecen a cookies httpOnly, invisibles a JavaScript por diseño. La comodidad de guardar el token en localStorage se paga con la mayor de las deudas de seguridad.

Los secretos no son el único dato que conviene mantener fuera del disco por razones ajenas a lo técnico. Los datos personales identificables —nombres, correos, ubicaciones, historiales— persistidos en el navegador amplían la superficie de exposición y caen bajo normativas como el RGPD, que preguntan por qué guardas lo que guardas y durante cuánto tiempo. La persistencia selectiva no es solo una optimización de rendimiento: es también minimización de datos, un principio que reduce a la vez tu riesgo de seguridad y tu carga de cumplimiento legal. Guardar menos es, casi siempre, guardar mejor.

El estado guardado es un formato con contrato

Aquí está la idea que cambia la forma de pensar la persistencia. En el momento en que tu app escribe en el disco de un usuario, has creado un formato de datos, y ese formato es ahora tan público y tan inmutable como una API que hubieras publicado. Está congelado con la forma exacta que tu estado tenía el día del despliegue, y permanecerá así en el navegador de ese usuario hasta que él vuelva —mañana, o dentro de un año— con una versión de tu app que quizá ya no reconoce esa forma.

La fusión con valores por defecto de la lección dos cubre el caso benigno: campos nuevos que no existían. Pero no cubre los cambios estructurales, que son los frecuentes en un proyecto vivo: renombrar un campo, partir uno en dos, cambiar el tipo de un valor, reorganizar un objeto anidado. Ante esos cambios, la fusión no basta; el dato viejo llega con una forma que el código nuevo malinterpreta, y ninguna cantidad de spread lo arregla.

Hay una asimetría cruel en este contrato: tú controlas cuándo cambias el código, pero no cuándo el usuario vuelve. Puedes desplegar diez versiones en un mes, pero un usuario que abrió la app en enero y regresa en diciembre saltará de la forma uno a la once de golpe, sin pasar por las intermedias en el tiempo real. Tu lógica de lectura tiene que estar preparada para recibir cualquier forma pasada, no solo la inmediatamente anterior, y esa es precisamente la razón por la que la migración no puede ser un único condicional sino una cadena capaz de cubrir todo el historial de un salto.

Versionado y migración

La solución es la misma que usan las bases de datos desde hace décadas: sella cada objeto persistido con un número de versión y, al rehidratar, compáralo con la versión actual del código. Si coinciden, usa el dato tal cual. Si el guardado es más viejo, pásalo por una cadena de migraciones que lo transforme paso a paso hasta la forma actual. Cada migración es una función pura de una versión a la siguiente.

const VERSION = 3
const migraciones: Record<number, (e: any) => any> = {
  1: (e) => ({ ...e, idioma: e.lang, lang: undefined }),   // renombrar campo
  2: (e) => ({ ...e, tema: e.oscuro ? 'oscuro' : 'claro' }), // cambiar tipo
  3: (e) => ({ ...e, borrador: { texto: e.borrador ?? '' } }) // partir en objeto
}

function migrar(blob: { version: number; estado: any }) {
  let { version, estado } = blob
  while (version < VERSION) {          // aplica en cadena: 1 -> 2 -> 3
    version++
    estado = migraciones[version](estado)
  }
  return estado
}

El caso que más se olvida es el del blob que no puedes migrar: una versión desconocida, una guardada por un despliegue futuro al que el usuario hizo rollback, o un dato corrupto. La respuesta correcta no es lanzar una excepción que rompe el arranque, sino el descarte seguro: si no sabes leer el formato, ignóralo y arranca con los valores por defecto. Perder una preferencia es una molestia; un arranque que revienta por un blob ilegible es un producto roto.

Nota un detalle fácil de pasar por alto: los blobs escritos por tu primera versión, antes de que existiera el versionado, no llevan número de versión. Trátalos como versión cero y haz que la ausencia del campo equivalga al arranque del historial de migraciones. Ese caso, el de los usuarios más antiguos, es precisamente el que más migraciones tendrá que atravesar y el que más fácil se olvida al probar.

flowchart TD
L[leer blob guardado] --> V{version del blob}
V -->|igual a la actual| U[usar tal cual]
V -->|menor| M[aplicar migraciones en cadena]
V -->|desconocida o mayor| R[descartar y reiniciar seguro]
M --> U
style U fill:#a6e3a1,color:#11111b
style R fill:#f9e2af,color:#11111b

Probar el contrato y sincronizar pestañas

Si el estado guardado es un contrato, las migraciones son cláusulas que hay que verificar, no código que se escribe y se olvida. Una migración rota no falla en tu máquina, donde ya tienes la forma nueva: falla meses después, en el navegador de un usuario que vuelve con la forma vieja, y para entonces nadie recuerda el cambio que la provocó. La defensa es guardar blobs reales de versiones antiguas como fixtures y afirmar en un test que la migración lleva cada uno hasta la forma actual sin perder datos.

// Guarda blobs reales de versiones pasadas y afirma que migran bien.
test('migra v1 hasta la version actual sin perder datos', () => {
  const v1 = { version: 1, estado: { lang: 'es', oscuro: true, borrador: 'hola' } }
  const actual = migrar(v1)
  expect(actual.idioma).toBe('es')                   // renombrado en v1 -> v2
  expect(actual.tema).toBe('oscuro')                 // tipo cambiado en v2 -> v3
  expect(actual.borrador).toEqual({ texto: 'hola' }) // partido en v3
})

De ahí se sigue una regla de higiene inflexible: una migración ya desplegada es inmutable. En cuanto sale a producción se ha ejecutado sobre discos que no controlas, y editarla cambia el resultado para quien aún no la había corrido, desalineando a unos usuarios con otros. Si te equivocaste, no corrijas la vieja: añade una migración nueva que repare. El historial de migraciones crece, nunca se reescribe, exactamente como el de una base de datos.

Queda un frente más, el de las pestañas. Cuando una pestaña migra y reescribe el blob, otras pestañas abiertas siguen con el estado viejo en memoria, ajenas al cambio. El evento storage vuelve a ser la herramienta: escúchalo para detectar que otra pestaña reescribió el almacén y decide de forma explícita qué hacer —recargar, fusionar o avisar—, porque ignorarlo deja conviviendo dos verdades en el mismo navegador.

Un cambio que casi nadie trata como migración y lo es: quitar un campo de la lista blanca. El código nuevo deja de escribirlo, pero los blobs viejos aún lo contienen, y ese dato huérfano se seguirá cargando e hidratando hasta que alguien lo limpie. Si el campo era sensible, sigue en el disco pese a que creíste haberlo eliminado. Retirar un campo persistido merece una migración que lo borre de forma explícita, no solo su desaparición del código.

// Retirar un campo del disco es tambien una migracion:
4: (e) => { const { tokenViejo, ...resto } = e; return resto } // borra el huerfano

Lista blanca

Deniega por defecto, incluye por excepción. partialize devuelve solo lo que el usuario esperaría reencontrar. Menos superficie de formato y menos secretos expuestos.

🔢

Versión sellada

Cada blob lleva su número de versión. Sin él no puedes saber con qué forma fue escrito ni si tu código actual sabe leerlo.

⛓️

Migración en cadena

Funciones puras de una versión a la siguiente, aplicadas en secuencia. Inmutables una vez desplegadas: el historial crece, no se reescribe.

🛟

Descarte seguro

Ante un blob ilegible, desconocido o corrupto, arranca con los valores por defecto en vez de reventar. Perder una preferencia es molesto; un arranque roto es un producto muerto.

💡
Sube la versión en cada cambio estructural, sin excepción

La disciplina que sostiene todo lo anterior es trivial de enunciar y fácil de olvidar bajo presión: cada vez que cambies la forma del estado persistido —renombrar, partir, cambiar un tipo, reorganizar un anidamiento— incrementa el número de versión y escribe la migración correspondiente. Añadir un campo con valor por defecto puede confiarse a la fusión; cualquier cosa más profunda exige versión nueva. El día que hagas un cambio estructural sin subir la versión, los datos viejos entrarán como si fueran nuevos y tu código los malinterpretará en el más absoluto silencio.

Persistir es publicar un esquema que te obligas a mantener para siempre

La conclusión que cierra el nivel entero es que la persistencia no es una función que llamas, es un compromiso que adquieres. Cada campo que decides guardar es una cláusula de un contrato con el futuro: prometes que las versiones venideras de tu app sabrán leer lo que esta versión escribió, o sabrán transformarlo, o sabrán descartarlo sin morir. Ese contrato no caduca cuando cambias de idea; vive en los discos de tus usuarios mientras ellos no borren los datos, y ellos no lo harán. De ahí se siguen las dos disciplinas de esta lección como una sola ética. La persistencia selectiva es minimizar la superficie del contrato: cuanto menos guardas, menos formato tienes que mantener compatible, menos secretos expones a una inyección, menos datos rancios contradicen a tu servidor. Y el versionado es asumir con honestidad que el contrato existe y darle mantenimiento explícito en lugar de fingir que el formato nunca cambiará. El ingeniero que vuelca el store entero sin versión no ha evitado el contrato: lo ha firmado a ciegas y sin cláusula de salida, y descubrirá su alcance el día que un cambio inocente de esquema empiece a corromper sesiones de usuarios que llevaban meses sin abrir la app. Guardar en el disco de otra persona es un acto de responsabilidad, no de comodidad. Persiste poco, séllalo con una versión, ten siempre una salida de emergencia hacia los valores por defecto, y habrás convertido una bomba de relojería en un contrato que puedes cumplir.

⚔️ Convierte tu persistencia en un contrato mantenible
  1. Reescribe tu persistencia como una función partialize de lista blanca que devuelva solo los campos que un usuario esperaría reencontrar, y justifica cada inclusión.
  2. Busca cualquier token o dato sensible que estés guardando en localStorage y planifica su traslado a una cookie httpOnly, razonando el riesgo de XSS que eliminas.
  3. Envuelve tu estado persistido en un sobre con un número de versión explícito y sella los datos que escribas de ahora en adelante.
  4. Simula un cambio de esquema —renombra o parte un campo— y escribe la migración que transforma la versión anterior en la nueva.
  5. Encadena dos migraciones y verifica que un blob de la versión más antigua llega correctamente hasta la forma actual pasando por todos los pasos.
  6. Provoca un blob ilegible o de versión desconocida y confirma que tu app lo descarta y arranca con valores por defecto en lugar de reventar.