SSR y persistencia: el desajuste de hidratación y esperar al cliente
Cuando el HTML lo genera un servidor, el estado guardado en el navegador se convierte en un dato que una de las dos mitades del sistema no puede conocer, y de ahí sale el desajuste de hidratación: el árbol que el servidor escribió y el que el cliente calcula no coinciden. Esta lección explica qué compara exactamente el reconciliador y por qué silenciar el aviso es la peor respuesta posible; describe el patrón de esperar al cliente con `skipHydration` y una rehidratación manual en un efecto, y su equivalente con la instantánea de servidor de `useSyncExternalStore`; y termina con la única solución que elimina el problema de raíz para datos como el tema, que es moverlos a una cookie que ambas mitades pueden leer.
Las cuatro lecciones anteriores dieron por supuesto que la aplicación arranca en el navegador y que el primer HTML lo produce el mismo código que después lo mantiene. Cuando ese HTML lo genera un servidor —en cada petición o de antemano en la compilación—, la persistencia deja de ser un problema de orquestación temporal y se convierte en algo más incómodo: un problema de conocimiento repartido. El servidor tiene que decidir qué escribir sin acceso al almacén del navegador, porque ese almacén pertenece a un dispositivo que no ha visto y a una sesión que no controla. El cliente, al hidratar, sí tiene ese acceso, y si lo usa antes de tiempo produce un árbol distinto del que llegó por la red. El aviso de desajuste que aparece entonces no es un capricho del reconciliador: es la notificación exacta de que dos mitades del mismo programa han llegado a conclusiones distintas sobre el estado del mundo.
- Explicar qué compara React al hidratar y por qué el estado persistido lo rompe.
- Aplicar el patrón de esperar al cliente con
skipHydrationy rehidratación manual. - Entender el papel de la instantánea de servidor en
useSyncExternalStore. - Mover a cookies los datos que ambas mitades necesitan conocer antes del primer pintado.
Dos mitades con información distinta
El renderizado en servidor divide la producción de la interfaz en dos actos. En el primero, una máquina sin ventana ni almacenamiento local ejecuta tus componentes y produce una cadena de HTML. En el segundo, el navegador recibe esa cadena, la pinta de inmediato y después ejecuta el mismo código para volver a calcular el árbol y adoptar el marcado ya existente en lugar de crearlo. Esa adopción es la hidratación, y su premisa es que el árbol calculado en el cliente coincida con el que el servidor escribió.
El estado persistido rompe la premisa por construcción. El servidor renderiza con el estado inicial porque es todo lo que tiene; el cliente, si su store ya ha leído el almacén en el momento de hidratar, renderiza con el estado guardado. Un usuario con tema oscuro recibe HTML claro y calcula HTML oscuro. Un usuario con tres artículos en el carrito recibe un contador a cero y calcula un tres. No hay error de programación en ninguna de las dos mitades: hay una asimetría de información que el diseño del sistema no había reconocido.
La propiedad que suprime el aviso de desajuste está pensada para valores que legítimamente difieren entre servidor y cliente, como una marca temporal, y actúa sobre un solo elemento y su texto o sus atributos. No repara el árbol ni sincroniza el estado. Peor aún: cuando el desajuste es estructural, el reconciliador descarta el marcado del servidor para ese subárbol y lo vuelve a construir en el cliente, de modo que pierdes exactamente el beneficio por el que estabas renderizando en servidor, y lo pierdes en silencio porque acabas de tapar el único indicador que te lo habría dicho.
sequenceDiagram participant S as servidor participant N as navegador participant A as almacen local S->>N: html generado con el estado inicial N->>N: pinta el html recibido N->>A: el store lee el almacen al crearse A-->>N: devuelve tema oscuro N->>N: hidrata y calcula un arbol distinto Note over N: hay desajuste y se rehace el subarbol entero
El patrón: esperar al cliente
La solución estructural consiste en imponer que el primer render del cliente sea idéntico al del servidor, cueste lo que cueste, y aplazar la entrada del estado guardado a un momento posterior a la hidratación. En Zustand la herramienta es explícita: una opción que desactiva la lectura automática del almacén, más una llamada manual a la rehidratación dentro de un efecto, que por definición se ejecuta solo en el cliente y solo después de que la hidratación haya terminado.
export const usePreferencias = create<Preferencias>()(
persist(inicializador, {
name: 'preferencias',
storage: createJSONStorage(() => localStorage),
skipHydration: true, // el servidor y el primer render coinciden
}),
)
// En el arranque del arbol de cliente, una sola vez.
useEffect(() => {
void usePreferencias.persist.rehydrate()
}, [])
Ese patrón tiene un coste que no siempre se admite: durante un breve tramo posterior a la hidratación, la aplicación muestra deliberadamente información que sabe incorrecta, porque ha decidido que la coherencia del árbol vale más que la exactitud momentánea. Es un intercambio razonable y conviene hacerlo con los ojos abiertos, porque explica por qué el desajuste desaparece y el parpadeo no: acabas de mover el problema de la lección anterior a un terreno donde ya sabes tratarlo, con retención selectiva y huecos reservados. Las dos lecciones se usan juntas, y quien solo aplica esta se sorprende de seguir viendo destellos.
En Redux el equivalente es no crear el persistor durante el render del servidor y montar la compuerta únicamente en el cliente, con un contenido de respaldo que coincida exactamente con lo que el servidor escribió. La regla es la misma en ambos ecosistemas y merece enunciarse aparte del API concreto: durante la hidratación, el estado debe ser el que el servidor pudo conocer; después, el que el usuario dejó.
skipHydration
Desactiva la lectura automática. Sin ella, un almacén síncrono se lee durante la creación del store y el desajuste es inevitable.
Rehidratar en un efecto
Los efectos no corren en el servidor y corren después de la hidratación. Es el único momento seguro para introducir el estado guardado.
Islas solo de cliente
En arquitecturas de islas, marcar como exclusivamente de cliente el componente que depende del almacén elimina el desajuste porque elimina el render de servidor.
Cookie
El único canal que ambas mitades leen. Convierte el dato en algo que el servidor sí puede conocer, y con ello disuelve el problema.
Hay un requisito previo a todo esto que conviene comprobar antes de perseguir desajustes, porque produce fallos mucho peores y se confunde con ellos: en el servidor, el store no puede ser un objeto de módulo compartido. Un store creado al evaluarse el módulo vive en el proceso y lo comparten todas las peticiones que ese proceso atiende, de modo que el estado de un usuario puede acabar renderizado en la respuesta de otro. La regla es crear un store por petición y entregarlo por contexto, y solo en el cliente tiene sentido que exista una única instancia.
// En servidor, una instancia por peticion; en cliente, una sola para toda la sesion.
let deCliente: Store | undefined
export function obtenerStore(inicial?: Partial<Estado>) {
if (typeof window === 'undefined') return crearStore(inicial) // nunca compartir
deCliente ??= crearStore(inicial)
return deCliente
}
Cuando el HTML se produce en la compilación en lugar de en cada petición, la asimetría no desaparece sino que se agrava: el marcado lo escribió una máquina que no solo desconocía el almacén de este usuario, sino que se ejecutó semanas antes y sirve el mismo resultado a todo el mundo. Todo lo de esta lección se aplica igual, con una salvedad importante en la vía de las cookies: no puede usarse tal cual, porque no hay petición durante la generación. En ese escenario el dato debe resolverse en el borde, con una transformación de la respuesta, o aceptar la vía de esperar al cliente.
La instantánea de servidor
Conviene entender por qué el mecanismo de suscripción de React exige una tercera función y no solo dos. El hook que conecta un store externo pide cómo suscribirse, cómo leer el valor actual y, aparte, cómo leerlo en el servidor. Esa tercera función existe precisamente porque el valor que el cliente puede leer y el que el servidor puede leer no son el mismo, y React necesita que durante la hidratación se use el segundo para que el árbol calculado coincida con el recibido.
const tema = useSyncExternalStore(
suscribirse,
() => almacenLocal.leerTema(), // cliente: puede consultar el navegador
() => 'claro', // servidor e hidratacion: valor conocido por ambos
)
La instantánea de servidor garantiza la coincidencia del primer render, pero no impide que el store ya haya cambiado por debajo. Si el middleware de persistencia leyó el almacén al evaluarse el módulo, el estado interno del store ya es el guardado antes de que React empiece a hidratar, y la instantánea del cliente devolverá ese valor en el render siguiente. La función resuelve la coherencia del árbol; desactivar la lectura automática resuelve el origen. Se usan juntas, no como alternativas.
En arquitecturas de islas, donde solo fragmentos concretos de la página llevan comportamiento, existe una salida adicional que no está disponible en una aplicación monolítica: declarar que la isla dependiente del almacén no se renderice en el servidor en absoluto. Se paga con un hueco en el HTML inicial —que hay que reservar con las dimensiones correctas para no provocar salto de disposición— y se gana la desaparición completa del problema para ese fragmento. Es la mejor relación entre coste y beneficio para widgets pequeños y personales, como un contador de carrito o un panel de preferencias.
Lo que sí puede viajar: cookies
Todo lo anterior gestiona la asimetría; las cookies la eliminan. Una cookie viaja en la petición, de modo que el servidor puede leerla antes de renderizar y producir directamente el HTML correcto: el atributo de tema ya puesto en el elemento raíz, la barra lateral ya plegada, el idioma ya elegido. No hay desajuste porque no hay dos informaciones distintas, y no hay parpadeo porque el primer pintado ya es el definitivo. Para el conjunto reducido de datos que la interfaz necesita antes del primer fotograma, es la única solución completa.
// En el servidor: el dato llega con la peticion y decide el marcado inicial.
const tema = leerCookie(peticion, 'tema') ?? 'claro'
// En el cliente: escribir la cookie ademas del store mantiene ambas mitades de acuerdo.
document.cookie = `tema=${nuevo}; path=/; max-age=31536000; samesite=lax`
Pregúntate si un usuario notaría el error en el primer fotograma. El tema lo notaría, porque un destello blanco en una pantalla oscura es agresivo e inmediato. El idioma lo notaría, porque el texto cambiaría entero. Las columnas visibles de una tabla no, porque llegan junto con los datos que la tabla muestra y nadie espera verlos antes. El carrito tampoco, porque su contenido no gobierna la disposición de la página. Solo lo que pasa la prueba merece el coste de viajar en cada petición.
El coste es real y hay que contarlo: la cookie se envía en cada petición al origen, así que solo compensa para datos diminutos; con caché en el borde, el marcado depende ahora de un valor que hay que incorporar a la clave de caché o desactivar la caché para esas rutas; y las cookies tienen sus propias reglas de tamaño, ámbito y consentimiento. La conclusión práctica es una división del trabajo: lo que debe estar bien en el primer fotograma viaja en cookie, lo demás vive en el almacén local y se rehidrata después de la hidratación.
Vale la pena resistirse a la lectura habitual del aviso de desajuste, que lo trata como una molestia del framework y busca la manera más corta de callarlo, porque esa lectura oculta lo que el aviso realmente está diciendo. El renderizado en servidor descansa sobre una equivalencia que casi nunca se enuncia: que tus componentes son funciones puras del estado y que, dado el mismo estado, producen el mismo árbol con independencia de dónde se ejecuten. Esa equivalencia es la que permite que una máquina calcule el resultado y otra lo adopte sin recalcularlo, que es todo el beneficio del método. La comprobación de hidratación no es más que la verificación de esa equivalencia en tiempo de ejecución, y cuando falla, está reportando algo verdadero y grave: que el estado no era el mismo, porque una de las dos ejecuciones tenía acceso a información que la otra no podía tener. El almacenamiento del navegador es el caso más limpio de esa asimetría, pero el patrón es general y aparece igual con la hora local, el ancho de la ventana, el idioma del sistema, la geolocalización o cualquier capacidad del dispositivo. De ahí que las soluciones legítimas se reduzcan a tres, y que las tres se dejen enunciar sin mencionar ninguna API: igualar la información hacia abajo, haciendo que el cliente finja durante la hidratación no saber más que el servidor; igualar la información hacia arriba, trasladando el dato a un canal que viaje con la petición para que el servidor lo conozca de verdad; o renunciar a la ejecución doble para ese fragmento, dejando que solo el cliente lo produzca. Suprimir el aviso no pertenece a esa lista porque no altera la información disponible en ninguna de las dos mitades: se limita a apagar el instrumento que estaba midiendo la discrepancia, y deja intacta la discrepancia. Reconocer esto convierte una clase entera de errores confusos en una sola pregunta, la misma siempre y contestable en un minuto: qué sabe esta mitad que la otra no puede saber.
- Monta una página renderizada en servidor con un store persistido y un tema. Provoca el desajuste guardando tema oscuro y recargando; lee el aviso completo antes de tocar nada.
- Comprueba en la pestaña de red y en el marcado inicial qué HTML llegó realmente, y contrasta con lo que la página muestra un instante después.
- Aplica la primera vía: desactiva la lectura automática, rehidrata en un efecto y verifica que el aviso desaparece. Anota qué parpadeo queda a cambio.
- Aplica la segunda vía para el tema: guárdalo en cookie, léelo en el servidor y comprueba que ni hay aviso ni hay parpadeo. Explica por qué esta vía no sirve para el carrito.
- Aplica la tercera vía a un widget pequeño renderizándolo solo en cliente, con hueco reservado. Compara el desplazamiento de disposición frente a la primera vía.
- Escribe en tres líneas el criterio con el que tu equipo elegirá entre las tres vías para el siguiente dato persistido que aparezca.