Hidratación al arrancar
Persistir tiene dos sentidos, y solo uno es obvio. Escribir el estado en el disco —deshidratarlo— es la mitad fácil: tomas una foto del store y la vuelcas a `localStorage` o `IndexedDB`. La mitad difícil es la vuelta: al arrancar de nuevo hay que leer esa foto muerta y devolverle la vida dentro de un store reactivo recién nacido. Ese acto de reconstitución es la hidratación, y el patrón que lo gobierna se llama rehydrate. Esta lección desmonta el ciclo completo deshidratar-rehidratar y se detiene en sus dos peligros silenciosos: la fusión —el estado persistido debe mezclarse con los valores por defecto, no reemplazarlos, o una versión futura de tu app arrancará con campos que no existían cuando se guardó la foto— y el momento —cuando el medio es asíncrono como `IndexedDB`, el store nace vacío y se hidrata un instante después, lo que obliga a levantar una compuerta que impida renderizar contra un estado a medio construir. Entender la hidratación es entender que un store persistente no arranca: renace.
Un store en memoria nace en un solo acto: le das un estado inicial y ya está vivo. Un store persistente no tiene ese lujo, porque su verdadero estado inicial no está en tu código sino en el disco, escrito por una sesión anterior que ya no existe. Arrancarlo es un ritual en dos tiempos: primero exhumar la fotografía que dejó esa sesión pasada, luego insuflarle vida fusionándola con los valores por defecto de la versión actual. A ese ritual lo llamamos hidratación, y su nombre no es casual: el estado serializado es un polvo deshidratado, inerte y ligero, que solo vuelve a ser estado vivo cuando se reconstituye dentro de un store en ejecución. La operación parece trivial —leer y asignar— pero esconde dos abismos. El primero es la fusión: si reemplazas en vez de mezclar, cualquier campo nuevo que tu código espera y que la foto vieja no tenía llegará como undefined y romperá en silencio. El segundo es el tiempo: si el medio es asíncrono, tu aplicación pintará su primer fotograma antes de que la hidratación termine, y renderizará contra un estado vacío que un parpadeo después cambiará bajo los pies del usuario.
- Descomponer la persistencia en sus dos sentidos: deshidratar al escribir y rehidratar al arrancar.
- Implementar el patrón rehydrate como una fusión entre estado persistido y valores por defecto, nunca como un reemplazo.
- Gestionar la hidratación asíncrona con una compuerta que impida renderizar contra un estado a medio construir.
- Reconocer el ciclo escribir-leer-fusionar que subyace a todo middleware de persistencia moderno.
Los dos sentidos de la persistencia
Persistir no es una operación, son dos que se turnan. Deshidratar es el sentido de salida: se toma una instantánea del estado vivo, se serializa y se escribe en el medio durable. Rehidratar es el sentido de entrada: se lee esa instantánea, se deserializa y se inyecta en un store que acaba de nacer. La app pasa la vida entera deshidratando —cada cambio se persiste— y rehidrata una sola vez, al arranque. Esa asimetría explica por qué el bug vive casi siempre en la rehidratación: es el paso que ocurre una vez y bajo condiciones difíciles de reproducir.
El corazón del patrón rehydrate es una palabra: fusión. El estado persistido no reemplaza al inicial, lo enriquece. Arrancas con los valores por defecto de la versión actual y encima aplicas lo que la sesión anterior guardó. Así, si tu código añadió un campo nuevo que la foto vieja no conocía, ese campo conserva su valor por defecto en lugar de aparecer como undefined.
const PORDEFECTO = { tema: 'claro', idioma: 'es', panelAbierto: false }
function rehidratar() {
const crudo = localStorage.getItem('ajustes')
if (!crudo) return PORDEFECTO
const guardado = JSON.parse(crudo)
return { ...PORDEFECTO, ...guardado } // fusion: defaults primero, guardado despues
}
const store = crearStore(rehidratar()) // el store nace ya hidratado
El operador de propagación fusiona solo un nivel. Si tu estado tiene objetos anidados —{ usuario: { nombre, prefs } }— un spread superficial reemplazará el objeto usuario entero con el guardado y perderá cualquier subcampo nuevo de la versión actual. Para estado anidado necesitas una fusión profunda campo a campo, o mejor, mantener el estado lo más plano posible precisamente para que la rehidratación sea una fusión de un solo nivel, predecible y barata.
El ciclo completo, escribir y leer
En la práctica no hidratas a mano cada campo: montas un ciclo que lee una vez al arrancar y escribe cada vez que el estado cambia. La escritura se suscribe a los cambios del store, y como esos cambios pueden llegar en ráfagas, se atenúa con un debounce para no martillear el disco en cada pulsación de tecla.
function persistir(store, clave, pordefecto) {
const inicial = { ...pordefecto, ...leer(clave) } // rehydrate
store.setState(inicial)
let temporizador
store.subscribe((estado) => { // dehydrate en cada cambio
clearTimeout(temporizador)
temporizador = setTimeout(() => {
localStorage.setItem(clave, JSON.stringify(estado))
}, 300) // debounce: escribe tras la calma
})
}
El debounce introduce un riesgo sutil: si el usuario cierra la pestaña dentro de la ventana de espera, la última escritura pendiente se pierde. La cura es vaciar el temporizador cuando la página se va, escuchando pagehide o visibilitychange y forzando una escritura síncrona final. Es de los pocos momentos en que se justifica un setItem fuera del ciclo normal.
addEventListener('pagehide', () => { // la pestana esta a punto de morir
clearTimeout(temporizador) // cancela el debounce pendiente
localStorage.setItem(clave, JSON.stringify(store.getState())) // vuelca ya
})
Este es exactamente el patrón que empaquetan los middlewares que ya conoces. El persist de Zustand, redux-persist o el persistAtom de Jotai no hacen magia distinta: rehidratan al construir el store y deshidratan en cada suscripción, con la fusión y el debounce incorporados. Verlos como una automatización de este ciclo, y no como una caja negra, te permite depurarlos cuando fallan.
Zustand · persist
Envuelve el store y expone partialize para elegir qué guardar, merge para la fusión y onRehydrateStorage para reaccionar al terminar. Su storage es intercambiable entre localStorage e IndexedDB.
redux-persist
El veterano del ecosistema Redux. Introduce un PersistGate que retrasa el render hasta que la rehidratación acaba, resolviendo la compuerta asíncrona por ti, y una whitelist para la persistencia selectiva.
Jotai · atomWithStorage
Persiste átomo a átomo en lugar del store entero. La granularidad es su virtud: cada pieza de estado decide por sí misma si se guarda, sin una configuración central de qué incluir.
flowchart LR D[medio durable] -->|leer una vez| R[deserializar] R -->|fusionar con defaults| S[store vivo] S -->|cada cambio| W[serializar] W -->|escribir con debounce| D style S fill:#a6e3a1,color:#11111b style R fill:#89b4fa,color:#11111b
Cuando la hidratación llega tarde
Todo lo anterior asume un medio síncrono. Con localStorage la rehidratación termina antes de que el primer render ocurra, así que el store nunca se ve vacío. Con IndexedDB la historia cambia por completo: leer devuelve una promesa, la app pinta su primer fotograma mientras la lectura sigue en vuelo, y el store nace con los valores por defecto. Un instante después la promesa resuelve, el store salta al estado guardado, y el usuario ve un parpadeo: el tema claro por defecto que de golpe se vuelve oscuro, el carrito vacío que de repente tiene tres artículos.
La solución es una compuerta. Mantienes una bandera hidratado en falso hasta que la lectura asíncrona resuelve, y hasta entonces no renderizas la interfaz real sino un esqueleto o un espacio neutro. Solo cuando la hidratación completa levantas la compuerta y dejas que la vista se pinte contra un estado ya definitivo.
const store = crearStore({ ...PORDEFECTO, hidratado: false })
get('estado').then((guardado) => { // IndexedDB, asincrono
store.setState({ ...PORDEFECTO, ...guardado, hidratado: true })
})
// En la vista: no pintes nada real hasta que la compuerta se abra.
// if (!estado.hidratado) return <Esqueleto />
Una alternativa a esconder toda la interfaz tras un esqueleto es hidratar por capas: renderiza de inmediato el armazón que no depende del estado persistido —la navegación, el encabezado— y aplica la compuerta solo al fragmento que sí lo necesita. Así el usuario ve algo útil al instante y solo la zona dependiente del disco espera a la hidratación. La compuerta debe ser tan pequeña como el estado que protege, nunca envolver la aplicación entera por comodidad.
No dispares efectos durante la hidratación
Hay una costura más, fina y traicionera, que casi nadie ve hasta que la sufre. Cuando rehidratas asignando el estado guardado con un setState, el store no distingue esa asignación de un cambio provocado por el usuario: notifica a todos sus suscriptores como si el dato acabara de cambiar en vivo. Si tenías un efecto escuchando ese fragmento —una llamada de analítica, una sincronización con el servidor, una notificación—, se disparará al arrancar, reaccionando a una restauración como si fuera una acción.
El resultado son bugs desconcertantes: un evento de analítica que se registra en cada carga sin que el usuario toque nada, o una petición de guardado que se dispara justo tras leer del disco. La cura es hacer que la hidratación sea un evento distinguible del cambio genuino. La vía limpia es inicializar el store con el valor ya en la construcción, de forma síncrona, para que no exista ningún evento de cambio que notificar. Cuando el medio es asíncrono y no queda más remedio que asignar después, marca la asignación para que los efectos la ignoren.
// MAL: hidratar via setState notifica a los suscriptores como un cambio real.
store.subscribe((s) => analitica.registrar('cambio_tema', s.tema)) // se dispara al hidratar
// BIEN: inicializa en la construccion, sincrono, sin evento de cambio.
const store = crearStore(rehidratar())
// Si es forzosamente asincrono, marca la hidratacion y guardala en los efectos.
store.setState({ ...guardado, _hidratando: true })
// dentro del efecto: if (s._hidratando) return
Interioriza esta distinción porque reaparece en todo el track: restaurar estado no es lo mismo que cambiarlo. Un cambio nace de una intención presente del usuario y merece efectos; una restauración recupera una intención pasada ya consumada y no debe volver a dispararlos. Los middlewares serios te dan un gancho —onRehydrateStorage en Zustand, la fase de REHYDRATE en redux-persist— precisamente para que puedas tratar ese instante como lo que es: un renacimiento, no una edición.
El error mental más caro es pensar que hidratar es solo asignar un valor inicial. No lo es. Hidratar es el punto exacto donde una fotografía tomada por un programa que ya murió —quizá una versión anterior de tu código, quizá hace meses— se encuentra con un programa vivo que puede ser distinto en su forma. Ese encuentro tiene dos costuras y en ambas se cuela el desastre. La primera costura es la forma: la foto vieja y el molde nuevo no coinciden campo a campo, y por eso la fusión con valores por defecto no es una comodidad sino un contrato de compatibilidad hacia atrás; sin ella, cada despliegue que añade un campo es una bomba de relojería en los navegadores de quienes ya tenían datos guardados. La segunda costura es el tiempo: entre que el store nace y que la hidratación asíncrona lo completa hay una ventana en la que el estado es una mentira transitoria, y renderizar dentro de esa ventana produce el parpadeo o, peor, dispara efectos contra un estado que aún no es el real. El ingeniero que entiende esto deja de preguntar cómo cargo el estado guardado y empieza a preguntar contra qué forma lo estoy fusionando y en qué instante puedo confiar en él. La hidratación no es la carga de un archivo: es el trasplante de una memoria de un cuerpo muerto a uno vivo, y todo trasplante exige comprobar compatibilidad y vigilar el rechazo.
- Escribe una función
rehidratarque lea delocalStorage, fusione con un objeto de valores por defecto y devuelva el estado inicial listo para el store. - Añade un campo nuevo a tus valores por defecto y verifica que un estado guardado antiguo —sin ese campo— arranca con el valor por defecto y no con
undefined. - Monta el ciclo de deshidratación suscribiéndote al store y escribiendo con un
debouncede trescientos milisegundos; comprueba en la consola que no escribe en cada tecla. - Repite el ejercicio con
IndexedDBmediante un wrapper y observa el parpadeo al arrancar sin compuerta. - Introduce una bandera
hidratadoy una compuerta que muestre un esqueleto hasta que la lectura asíncrona resuelva; confirma que el parpadeo desaparece. - Abre el código fuente del middleware
persistque uses y localiza sus dos mitades: dónde rehidrata al construir y dónde deshidrata al suscribirse.