wandres.dev
PERSISTENCIA · guardar y rehidratar

La rehidratación: la ventana entre el primer pintado y el estado real

Entre el momento en que la aplicación arranca con el estado que declara su código y el momento en que el estado guardado entra en el store existe una ventana, y todo lo desagradable de la persistencia ocurre dentro de ella. Esta lección la describe con precisión: por qué existe incluso con almacenes síncronos, qué la alarga, y cuáles son sus tres manifestaciones visibles —el destello del tema equivocado, el vacío que se llena de golpe y el falso deslogueo que dispara una redirección—. Después compara las tres familias de solución: retener el pintado con una compuerta, condicionar solo el subárbol dependiente mediante una bandera de hidratación completada, y sacar el dato del ciclo de JavaScript para que el navegador lo aplique antes de pintar nada.

⏱ 19 min

Rehidratar es el instante en que un estado escrito por una ejecución anterior entra en el store de la ejecución actual, y ese instante nunca es el instante cero. Siempre hay un antes: un tramo en el que la aplicación existe, tiene estado, se renderiza y responde, pero el estado que tiene es el que declara el código y no el que el usuario dejó. Esa ventana puede durar un fotograma o medio segundo, y su duración importa menos que su existencia, porque cualquier decisión visible que tu interfaz tome dentro de ella tendrá que deshacerse en cuanto la ventana se cierre. El usuario percibe ese deshacer como un fallo de calidad —un parpadeo, un salto, una redirección absurda— y no tiene forma de saber que está viendo la costura entre dos fuentes de verdad. Entender la ventana es entender por qué la persistencia, que parece un problema de almacenamiento, es en realidad un problema de orquestación temporal.

🎯 Al terminar esta lección sabrás
  • Explicar por qué existe una ventana de rehidratación incluso con un almacén síncrono.
  • Identificar las tres manifestaciones visibles del problema y su causa común.
  • Comparar las tres familias de solución y elegir según el dato y el coste.
  • Aplicar la técnica adecuada para que el tema visual no parpadee nunca.

Por qué existe la ventana

El almacenamiento local del navegador es síncrono, así que la intuición dice que el estado guardado debería estar disponible antes del primer render y la ventana no debería existir. Es cierto solo en el caso más favorable, y conviene ver por qué se rompe en todos los demás. Se rompe cuando el almacén es asíncrono, que es lo normal fuera de la web más simple: una base de datos indexada, un almacén nativo en móvil o cualquier cosa que cruce un proceso devuelve una promesa, y la lectura de una promesa no puede terminar antes del primer render por definición.

Se rompe también cuando la herramienta decide tratar la lectura como asíncrona por uniformidad, que es lo que hace redux-persist: su ciclo despacha una acción de arranque, encola la lectura y despacha la rehidratación después, de modo que hay al menos una actualización de por medio aunque el dato estuviera disponible al instante. Y se rompe, por último, cuando el código se parte en fragmentos: el trozo que contiene el store puede llegar más tarde que el trozo que pinta la cabecera, y entonces la ventana no la abre el almacén sino la red.

⏱️

Almacén asíncrono

Cualquier almacén que devuelva una promesa garantiza al menos un ciclo con el estado inicial en pantalla. Es el caso de la base de datos indexada y del almacenamiento nativo.

🔁

Ciclo por acción

Cuando la rehidratación se modela como acción, hay una actualización obligatoria entre el arranque y la llegada del dato, aunque la lectura sea instantánea.

📦

Fragmentación del código

Si el store viaja en un fragmento cargado bajo demanda, la ventana la marca la latencia de red y puede durar cientos de milisegundos.

🖥️

Render en servidor

El caso extremo: el primer HTML lo produjo una máquina que no tiene acceso al almacén del navegador y por tanto no puede conocer el dato guardado.

sequenceDiagram
participant N as navegador
participant A as aplicacion
participant S as almacen
N->>A: ejecuta el arranque
A->>A: crea el store con el estado del codigo
A->>N: primer pintado con tema claro
A->>S: lee la clave guardada
S-->>A: devuelve tema oscuro
A->>A: funde y actualiza el store
A->>N: segundo pintado con tema oscuro
Note over N,A: la ventana es todo lo que hay entre los dos pintados

Las tres formas del parpadeo

La primera y más conocida es el destello del tema equivocado: la página aparece clara durante uno o dos fotogramas y se vuelve oscura. Es visualmente agresiva, ocurre en cada carga y en pantallas oscuras resulta físicamente molesta. La segunda es el vacío que se llena de golpe: el carrito muestra cero artículos, la barra lateral aparece desplegada, la tabla muestra sus columnas por defecto, y todo se reordena un instante después provocando un salto de disposición que además penaliza las métricas de estabilidad visual.

La tercera es la más grave porque no es cosmética: el falso deslogueo. Si el guardián de rutas pregunta por un dato de sesión que todavía no ha llegado, concluye que no hay usuario y redirige a la pantalla de acceso; medio segundo después el dato llega, el guardián cambia de opinión y devuelve al usuario a donde estaba. El resultado es una navegación fantasma que además puede destruir estado no guardado del formulario que el usuario tenía abierto.

⚠️
Distinguir ausencia de negación

Las tres formas comparten una causa lógica: el código interpreta un valor todavía no cargado como si fuera un valor conocido. El estado inicial dice claro y el código lo lee como si el usuario hubiera elegido claro; dice sin sesión y el código lo lee como si el usuario estuviera desconectado. La corrección conceptual, anterior a cualquier técnica, es admitir un tercer valor: no lo sé todavía. Un dato persistido tiene tres estados posibles y no dos, y cualquier rama del código que no contemple el tercero producirá una decisión que habrá que deshacer.

Hay una cuarta manifestación menos visible y más insidiosa que conviene nombrar: el efecto que se dispara dentro de la ventana. Si un componente lanza una petición al montarse usando un filtro que todavía no ha llegado del almacén, la petición sale con el filtro por defecto, el servidor responde, la respuesta se pinta, y un instante después llega el filtro guardado y todo se repite. El usuario ve dos cargas donde debería haber una, y el servidor recibe el doble de tráfico del necesario en cada arranque de cada sesión.

📝
Un solo lugar donde se decide, no veinte

La tentación al descubrir estas soluciones es esparcir comprobaciones de hidratación por toda la interfaz, y el resultado es una aplicación donde cada componente pregunta a su manera si ya puede confiar en el estado. Es mejor concentrar la respuesta en un único hook o selector con nombre propio y consumirlo desde donde haga falta: además de evitar la duplicación, deja un punto obvio donde poner un registro temporal cuando alguien tenga que investigar por qué una pantalla concreta tarda en asentarse.

Las tres familias de solución

La primera familia retiene el pintado hasta que el estado guardado ha llegado. En Redux se hace con la compuerta que acompaña a la librería; en Zustand, con una bandera que consulta si la hidratación terminó. Es la solución más sencilla de razonar y la más fácil de aplicar mal, porque quien la descubre tiende a envolver la aplicación entera y convierte un parpadeo de dos fotogramas en una pantalla en blanco de medio segundo, que es un empeoramiento medible del tiempo de pintado del contenido principal.

// Zustand: una bandera que distingue los tres estados posibles.
function useHidratado() {
  const [listo, setListo] = useState(usePreferencias.persist.hasHydrated())
  useEffect(() => {
    const fin = usePreferencias.persist.onFinishHydration(() => setListo(true))
    return fin
  }, [])
  return listo
}
// Redux: la compuerta acepta un contenido de espera y una llamada al terminar.
const raiz = h(Provider, { store }, [
  h(PersistGate, { persistor, loading: null }, [h(App)]),
])

// El equivalente sin compuerta: observar el estado del persistor a mano.
persistor.subscribe(() => {
  const { bootstrapped } = persistor.getState()
  if (bootstrapped) marcarListo()
})

La segunda familia es la misma técnica aplicada con bisturí: no se retiene la aplicación, se retiene únicamente el subárbol cuyo aspecto depende del dato guardado, y mientras tanto se reserva su espacio con un contenedor de las mismas dimensiones. Se conserva el pintado inmediato de todo lo que no depende del almacén, se elimina el salto de disposición y el usuario ve una carga honesta en lugar de una respuesta falsa. Es la opción correcta para carritos, paneles plegables, tablas con columnas configurables y prácticamente todo lo que no sea el tema.

// Retener solo lo que depende del dato, y reservar su hueco.
function Carrito() {
  const listo = useHidratado()
  const lineas = usePreferencias((s) => s.lineas)
  // el hueco tiene las mismas dimensiones que el contenido final
  if (!listo) return h('div', { className: 'h-12 w-24 animate-pulse rounded' })
  return h(Contador, { n: lineas.length })
}
💡
Para el tema, ninguna de las dos: sácalo del ciclo de JavaScript

El tema visual es el único caso donde retener el pintado no vale, porque lo que hay que evitar es precisamente el primer pintado incorrecto. La solución estándar no pasa por el store: un fragmento de código bloqueante en la cabecera del documento lee la clave guardada y coloca un atributo o una clase en el elemento raíz antes de que el navegador pinte una sola línea. El estilo depende de ese atributo, no del estado de la aplicación, y el store se limita a reflejarlo después. Es la aplicación literal del principio de que un dato necesario antes del primer pintado no puede depender de nada que ocurra después de él.

// Fragmento bloqueante en la cabecera: ocurre antes del primer pintado.
const t = localStorage.getItem('tema')
const oscuro = t ? JSON.parse(t) === 'oscuro' : matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.tema = oscuro ? 'oscuro' : 'claro'

Merece la pena insistir en el detalle del hueco reservado, porque es lo que separa una carga honesta de un salto molesto. Si el contenido definitivo ocupa cuarenta y ocho píxeles de alto y el contenido provisional ocupa cero, el resto de la página se desplazará cuando llegue el dato, y ese desplazamiento cuenta como inestabilidad visual en cualquier medición de calidad. Reservar el espacio exacto convierte la ventana en un detalle imperceptible: algo aparece donde ya había sitio para ello, que es como el usuario espera que se comporte una interfaz.

📝
El valor por defecto también es una decisión de producto

Mientras la ventana está abierta hay que pintar algo, y esa elección no es solo técnica. Para una barra lateral, mostrarla plegada molesta menos que mostrarla desplegada y esconderla después. Para un contador de carrito, no mostrar número molesta menos que mostrar un cero que resulta falso. La heurística general es elegir el estado provisional que menos afirme: entre dos opciones, la que hace una promesa más débil al usuario es la que produce menos sensación de error cuando se corrige.

// Un unico punto donde se decide que se pinta en cada uno de los tres estados.
type Fase = 'desconocido' | 'hidratado' | 'vacio'

function fase(listo: boolean, hay: boolean): Fase {
  if (!listo) return 'desconocido'
  return hay ? 'hidratado' : 'vacio'
}

La tercera familia elimina el problema por diseño en lugar de gestionarlo: hacer que la decisión no dependa del dato persistido. El caso claro es la autenticación. Un guardián de rutas nunca debería derivar su decisión de un valor rehidratado del almacén del navegador, porque ese valor no es autoridad —el servidor puede haber revocado la sesión mientras el usuario no estaba— y porque llega tarde. Si la decisión la toma el servidor, o un estado de sesión que arranca explícitamente en desconocido y solo se resuelve tras verificar, el falso deslogueo desaparece sin necesidad de compuertas.

El parpadeo no es un defecto visual: es la manifestación de un estado que el programa afirma sin haberlo comprobado

Lo que hace tan instructivo el destello de la rehidratación es que su fealdad es solo el síntoma de un error de tipos que casi ningún sistema de tipos atrapa. En el momento del primer render, el valor del tema no es claro: es desconocido, y el programa lo representa con el mismo símbolo que usa para el valor conocido claro. Esa colisión —codificar la ausencia de información con el valor por defecto del dominio— es exactamente el mismo error de modelado que produce el nulo tratado como cero, la lista vacía indistinguible de la lista no cargada y el usuario anónimo indistinguible del usuario no verificado; y la persistencia lo vuelve visible porque el navegador pinta la consecuencia. De ahí se sigue por qué las soluciones que funcionan comparten una estructura y las que no funcionan comparten otra: las que funcionan añaden el tercer valor al modelo, ya sea reteniendo el render mientras el dato es desconocido, reservando espacio para él o adelantando su resolución a un punto anterior al primer pintado; las que no funcionan intentan hacer el parpadeo menos visible con transiciones, opacidades o retardos, es decir, disimulan la consecuencia sin tocar la causa. La lección se generaliza más allá de la persistencia y es una de las más rentables del oficio: cada vez que un valor tiene un estado de indeterminación real, ese estado debe existir en el tipo, porque si no existe en el tipo existirá igualmente en el tiempo, y entonces se manifestará en el peor sitio posible, que es la pantalla del usuario. Y aún hay un corolario más severo para el caso de la sesión: allí el estado que el programa afirma sin comprobar no produce un destello sino una redirección, lo que demuestra que la diferencia entre un problema cosmético y uno funcional no está en la naturaleza del error sino en la importancia de la decisión que se toma con él.

⚔️ Mide la ventana y ciérrala tres veces
  1. Ralentiza artificialmente tu almacén envolviéndolo en un adaptador que espere trescientos milisegundos, y observa la ventana con calma en lugar de intuirla.
  2. Documenta las tres formas del parpadeo en tu aplicación: tema, contenido y sesión. Si alguna no aparece, explica qué la está evitando ya.
  3. Aplica una compuerta a la aplicación entera y mide el tiempo hasta el pintado del contenido principal antes y después. Anota cuánto empeora.
  4. Sustitúyela por retención selectiva del subárbol dependiente, con hueco reservado, y vuelve a medir. Compara también el desplazamiento de disposición.
  5. Saca el tema del store con un fragmento bloqueante en la cabecera y comprueba con una grabación de vídeo que ningún fotograma muestra el tema equivocado.
  6. Revisa tu guardián de rutas y reescríbelo para que distinga sesión desconocida de sesión ausente. Provoca el falso deslogueo antes del cambio y confirma que desaparece después.