wandres.dev
ESTADO EN SSR · serializar e hidratar

El mismatch de hidratación

Hidratar no es volver a renderizar: es adoptar. El framework recibe un árbol de nodos que el servidor ya pintó y, en vez de recrearlo, calcula su propio primer render y lo empareja nodo a nodo con lo recibido para colgarle la interactividad encima. Esa adopción descansa sobre un contrato duro que casi nadie enuncia en voz alta: el primer render del cliente debe ser una función determinista de exactamente la misma información que tuvo el servidor. Esta lección disecciona el contrato, cataloga las cinco fuentes de divergencia que lo rompen en la práctica —tiempo, azar, entorno del navegador, identidad de usuario y locale—, explica qué hace realmente el reconciliador cuando los árboles no encajan y por qué el coste no es cosmético, y desarrolla las estrategias de reparación que no obligan a renunciar al renderizado en servidor, incluida la lectura correcta de una fuente externa mediante `useSyncExternalStore` y su instantánea de servidor.

⏱ 19 min

El nombre engaña. Hidratación sugiere añadir agua a algo seco, un proceso suave y aditivo, cuando lo que ocurre es una operación de emparejamiento estricta y sorprendentemente frágil. El navegador ya tiene un árbol de nodos completo, pintado, visible, entregado por el servidor. El framework arranca, ejecuta el mismo componente que el servidor ejecutó y produce su propia descripción del árbol. Entonces recorre ambos en paralelo y, en lugar de crear nodos, los adopta: este div que ya existe corresponde a este elemento que acabo de calcular, así que le engancho los manejadores y sigo. Toda la ganancia del SSR vive en esa adopción, porque es lo que evita destruir y reconstruir un DOM que ya estaba bien. Y toda la fragilidad vive ahí también, porque adoptar solo es seguro si los dos árboles coinciden. La coincidencia no es una recomendación de estilo: es una precondición de corrección. Si el primer render del cliente difiere del HTML servido, el framework se encuentra con un DOM que no describe el estado que él cree tener, y a partir de ahí cualquier actualización posterior estaría parcheando una realidad equivocada. De ahí la severidad de la respuesta. Entender el contrato —qué exige exactamente, y qué lo rompe— es la diferencia entre silenciar avisos a ciegas y diseñar componentes que sencillamente no pueden divergir.

🎯 Al terminar esta lección sabrás
  • Enunciar el contrato de hidratación: primer render determinista sobre información idéntica a la del servidor.
  • Catalogar las cinco fuentes de divergencia y reconocerlas en el código antes de que produzcan un aviso.
  • Describir qué hace el reconciliador ante un mismatch y por qué el coste anula la ventaja del SSR.
  • Aplicar reparaciones que conservan el renderizado en servidor, incluida la instantánea de servidor de una fuente externa.

El contrato: determinismo sobre información compartida

El contrato tiene dos mitades, y casi todos los fallos vienen de olvidar la segunda. La primera mitad es el determinismo: dado el mismo estado de entrada, el componente debe producir siempre la misma salida. La segunda mitad es la que se pasa por alto: ese estado de entrada tiene que ser información de la que ambos entornos disponen. Un componente puede ser perfectamente determinista y aun así romper la hidratación, si su entrada es un dato que solo existe en el navegador.

Formulado así, el contrato se vuelve una guía de diseño. Antes de escribir un componente que se renderizará en servidor, la pregunta no es qué quiero mostrar, sino sobre qué información voy a decidir qué mostrar, y de dónde sale esa información. Si sale de la petición HTTP —URL, cabeceras, cookies— o de datos que el servidor incrusta al deshidratar, el contrato se cumple sin esfuerzo. Si sale del reloj, del azar o de una API del navegador, está roto desde antes de escribir la primera línea.

Hay además una simetría que ayuda a razonar: el contrato no exige que el HTML sea correcto, exige que sea el mismo. Un servidor que pinta un valor equivocado pero que el cliente reproduce exactamente hidrata sin un solo aviso, y el error se manifestará como un dato erróneo en pantalla, no como una divergencia. Al revés, un servidor que pinta el valor correcto y un cliente que pinta otro igual de correcto rompe la adopción. Lo que el framework compara es igualdad estructural, no verdad, y confundir ambas cosas lleva a buscar el bug en el lugar equivocado.

Conviene notar que el contrato solo rige el primer render. En cuanto la hidratación termina y el árbol queda adoptado, el componente puede leer lo que quiera, actualizarse cuantas veces quiera y divergir todo lo que haga falta del HTML original. La restricción es puntual y quirúrgica: se aplica a un único fotograma, el fotograma cero. Toda la ingeniería de reparación consiste en trasladar la lectura problemática justo un instante después de ese fotograma.

Las cinco fuentes de divergencia

Casi todos los mismatches del mundo real caen en cinco familias. Reconocerlas de vista es más útil que cualquier lista de soluciones, porque permite detectarlas en revisión de código en lugar de en producción.

⏱️

Tiempo

Leer el reloj en render. El servidor pinta un instante y el cliente otro, unos milisegundos o unos minutos después si hubo cache. Los textos de tipo hace dos minutos son el caso canónico.

🎲

Azar

Cualquier identificador aleatorio generado durante el render. El servidor genera uno, el cliente genera otro, y los atributos no coinciden. Los frameworks ofrecen generadores de identificador estables justo por esto.

🌐

Entorno del navegador

Consultar window, localStorage, matchMedia o las dimensiones de la ventana durante el render. El servidor no tiene ninguna de esas APIs y produce un valor neutro que el cliente contradice.

🪪

Identidad y locale

Interfaz que depende de si hay sesión, o textos formateados según la zona horaria y el idioma del navegador. El servidor formatea con su propia configuración regional, que rara vez es la del usuario.

Las dos primeras familias tienen una solución tan mecánica que conviene automatizarla mentalmente. El tiempo se resuelve fijando el instante en el servidor y deshidratándolo junto al resto del estado, de modo que ambos extremos formateen sobre la misma marca. El azar se resuelve con el generador de identificadores estables que los frameworks modernos ofrecen justo para esto: produce el mismo valor a ambos lados porque lo deriva de la posición del componente en el árbol, no de una fuente aleatoria.

// El instante lo fija el servidor y viaja deshidratado
const publicado = new Date(props.publicadoISO) // misma marca en ambos lados

// El identificador lo deriva el framework de la posicion en el arbol
const idCampo = useId() // estable entre servidor y cliente

La quinta familia es más sutil y por eso la más costosa de depurar: el HTML inválido reescrito por el analizador. Si generas marcado que viola las reglas de anidamiento del estándar —un párrafo dentro de otro párrafo, un div dentro de un p, una fila de tabla sin su cuerpo—, el analizador del navegador lo corrige silenciosamente al construir el DOM. El árbol que el cliente calcula es entonces el que tú escribiste, pero el árbol que existe en la página es el que el analizador decidió, y no coinciden. El aviso apunta a la hidratación cuando el problema real está en la estructura del marcado.

// ROMPE EL CONTRATO: tres entradas que el servidor no comparte
function Cabecera() {
  const ahora = Date.now()                       // tiempo
  const id = Math.random().toString(36)          // azar
  const ancho = window.innerWidth                // entorno del navegador
  return { ahora, id, ancho }
}
flowchart TD
A[HTML del servidor ya en el DOM] --> C[primer render del cliente]
C --> Q{arbol calculado igual al recibido}
Q -->|si| AD[adopcion: se cuelgan los manejadores y se reutilizan nodos]
Q -->|no| DE[el subarbol se descarta y se vuelve a crear en el cliente]
DE --> P[parpadeo, trabajo duplicado y ventaja de SSR perdida]
style AD fill:#a6e3a1,color:#11111b
style P fill:#f38ba8,color:#11111b

Qué ocurre realmente cuando no coinciden

La respuesta del framework ha cambiado con el tiempo y esa historia explica por qué el mismo bug produce síntomas distintos en proyectos distintos. Las implementaciones antiguas parcheaban: detectaban la diferencia en un atributo y lo corregían en el sitio, dejando el resto del árbol adoptado. Era barato y catastrófico, porque el DOM quedaba en un estado híbrido que ningún render había producido nunca, con bugs que aparecían tres interacciones después.

Las implementaciones modernas prefieren la corrección a la eficiencia: al detectar la divergencia, descartan el subárbol servido y lo vuelven a construir desde cero en el cliente. La vista queda correcta, pero se ha pagado dos veces por el mismo árbol —una en el servidor, otra en el cliente— y se ha regalado exactamente la ventaja por la que se adoptó el SSR. Si el mismatch ocurre cerca de la raíz, el descarte puede alcanzar a la página entera.

El coste real conviene desglosarlo porque suele subestimarse. Se paga tiempo de servidor en generar un HTML que se va a tirar; se paga ancho de banda en transmitirlo; se paga tiempo de cliente en analizarlo y construir un DOM que se va a destruir; y se paga otra vez en construir el DOM definitivo, ahora en el hilo principal y compitiendo con todo lo demás que el navegador tiene que hacer al arrancar. Un mismatch no es un aviso que se pueda posponer a la próxima revisión: es la anulación del motivo por el que se aceptó la complejidad del SSR.

En arquitecturas de islas hay una atenuante importante: el descarte queda confinado a la isla afectada y el resto del documento permanece intacto e interactivo. Eso convierte el tamaño de las islas en una decisión de riesgo, no solo de organización. Islas pequeñas y bien delimitadas alrededor del estado problemático limitan el radio de la explosión; una isla enorme que envuelve media página convierte cualquier divergencia menor en un descarte masivo.

⚠️
La ausencia de aviso no demuestra la ausencia de mismatch

Los avisos de hidratación suelen estar activos solo en desarrollo, y algunos casos ni siquiera se detectan: diferencias en atributos que el framework no compara, contenido dentro de un nodo cuyo marcado se inyecta como HTML crudo, o divergencias que el analizador ya había normalizado. Una consola limpia significa que no se detectó nada, no que todo coincidiera. La única verificación fiable es estructural: revisar de dónde sale la información que gobierna cada primer render.

Reparar sin renunciar al servidor

Hay tres movimientos, y están ordenados de mejor a peor porque cada uno concede más terreno que el anterior.

Antes de aplicar ninguno conviene un paso previo que ahorra mucho tiempo: localizar el nodo exacto donde empieza la divergencia. Los avisos modernos incluyen un fragmento del árbol servido junto al calculado, y comparar ambos suele señalar la entrada culpable de inmediato. Cuando el aviso es vago, la técnica más rápida es bisecar: renderizar la mitad del subárbol, comprobar si el aviso persiste, y repetir. En cinco o seis pasos se aísla el componente responsable sin leer una sola línea de más.

El primero es darle al servidor la información. Si el dato puede viajar en la petición —una cookie de tema, un encabezado de idioma, la sesión resuelta en el servidor— el contrato se cumple sin trucos, porque ya no hay dos verdades. Es la única solución que conserva íntegro el beneficio del SSR.

El segundo es posponer la lectura un fotograma. El componente renderiza el valor neutro que el servidor también produciría, y solo después de montar lee la fuente real y se actualiza. Se acepta un parpadeo a cambio de una adopción limpia. La forma canónica de hacerlo bien cuando el dato vive en una fuente externa no es un efecto suelto, sino declarar explícitamente la instantánea que el servidor debe usar.

// Lectura de una fuente externa con instantanea de servidor explicita
const ancho = useSyncExternalStore(
  suscribirse,                 // alta y baja del listener
  () => window.innerWidth,     // instantanea en el cliente
  () => 1024,                  // instantanea en el servidor: valor neutro
)

El tercero es declarar la divergencia como legítima. Existen casos donde dos valores distintos son ambos correctos: una fecha formateada en la zona horaria de cada extremo, o un contador de tiempo transcurrido. Marcar ese nodo concreto como excepción evita el aviso sin ocultar los demás. Es un bisturí: se aplica al nodo mínimo, jamás a un subárbol, porque una excepción amplia enmascara mismatches reales durante meses.

💡
Ordena las reparaciones por lo que cada una concede

Las tres opciones no son intercambiables y elegir mal sale caro. Dar la información al servidor no concede nada: conserva el HTML correcto, el pintado temprano y la adopción limpia. Posponer la lectura concede un fotograma de contenido neutro, aceptable si el valor por defecto no parece roto y no desplaza el diseño al corregirse. Declarar la divergencia como legítima no concede rendimiento pero renuncia a la detección en ese punto, que es una deuda silenciosa. Recórrelas siempre en ese orden y baja un peldaño solo cuando el anterior sea imposible, no cuando sea incómodo.

El contrato no es una restriccion del framework: es la definicion de lo que hace posible el SSR

Resulta tentador leer el mismatch como una imposición arbitraria, una rigidez que el framework nos impone y que nos toca sortear con parches. La lectura correcta es la inversa. El SSR entrega una promesa concreta: pintar antes de que el JavaScript llegue y, cuando llegue, no tirar ese trabajo a la basura. La segunda mitad de la promesa —no tirar el trabajo— solo se puede cumplir si el cliente es capaz de reconocer, en el DOM que encuentra, el mismo árbol que él habría construido. Reconocer es emparejar, y emparejar exige igualdad. El contrato de hidratación, por tanto, no es una regla añadida sobre el SSR: es literalmente el enunciado de la condición bajo la cual el SSR ahorra trabajo en lugar de duplicarlo. Cuando lo rompes, no estás incumpliendo una norma, estás anulando el teorema. Y de ahí se sigue la consecuencia de diseño más importante de todo este nivel: la manera de escribir componentes compatibles con el servidor no consiste en evitar ciertas APIs por superstición, sino en ser explícito sobre el origen de la información que gobierna cada render. Un componente cuyas entradas provienen de la petición y de datos deshidratados es indestructible por construcción, aunque nadie haya pensado nunca en la hidratación al escribirlo. Un componente que lee el reloj, el azar o el navegador en render es incorrecto aunque hoy no dispare ningún aviso, porque su corrección depende de una coincidencia que nadie le garantizó. El objetivo no es apagar avisos: es que la clase de código que puede producirlos deje de existir en tu base de código.

⚔️ Audita el fotograma cero
  1. Recorre tus componentes renderizados en servidor y anota, para cada uno, de dónde sale la información que gobierna su primer render. Clasifícala en petición, datos deshidratados, reloj, azar o navegador.
  2. Provoca deliberadamente cada una de las cinco divergencias en un componente de prueba y observa qué aviso produce cada una y en qué se diferencian los síntomas.
  3. Genera marcado inválido a propósito —un bloque dentro de un párrafo— y comprueba que el aviso de hidratación aparece aunque tu código sea perfectamente determinista.
  4. Convierte una lectura de window en render a una lectura con useSyncExternalStore que declare su instantánea de servidor, y verifica que el aviso desaparece.
  5. Mueve una preferencia de localStorage a una cookie y confirma que el HTML servido ya llega correcto, sin parpadeo ni segundo render.
  6. Aplica la excepción de divergencia legítima al nodo mínimo de una fecha y razona por escrito por qué en ese punto concreto dos valores distintos son ambos correctos.