wandres.dev
HIDRATACIÓN · El impuesto del SSR

Hidratación parcial y selectiva: hidratar menos y en mejor orden

La diferencia entre no hidratar un componente y hidratarlo más tarde, dónde poner la frontera cliente-servidor, y por qué el clic durante la carga no se pierde.

⏱ 19 min

Dos técnicas con nombres parecidos y efectos muy distintos. La hidratación parcial decide qué componentes reciben JavaScript, y lo que queda fuera no cuesta nada nunca. La selectiva decide en qué orden se hidrata lo que sí va a hidratarse, priorizando aquello con lo que el usuario acaba de interactuar. La primera reduce el trabajo total; la segunda lo reordena para que el usuario no lo note. Se combinan, y confundirlas lleva a esperar de una lo que solo da la otra.

🎯 Al terminar esta lección sabrás
  • Distinguir hidratación parcial de hidratación selectiva por su efecto sobre el trabajo total.
  • Colocar la frontera entre servidor y cliente donde minimiza a la vez código y datos.
  • Explicar el mecanismo de reproducción de eventos que evita perder el clic durante la carga.
  • Detectar el error de colocación de frontera que anula todo el beneficio.

Parcial y selectiva no son lo mismo

Hidratación parcial. Solo algunos componentes se hidratan; el resto es HTML y nada más. El código de esos componentes ni siquiera se envía al navegador. El coste pasa de ser proporcional al árbol entero a serlo únicamente a los subárboles interactivos.

Hidratación selectiva. Todo el árbol se hidrata, pero no de golpe ni en cualquier orden. El framework divide el trabajo en unidades independientes y prioriza la que contiene el elemento con el que el usuario ha interactuado. El trabajo total no baja; lo que baja es la latencia percibida de la primera interacción.

Puestas en la aritmética del nivel anterior: la parcial reduce el numerador, la selectiva reduce la varianza. Ambas ayudan y hacen cosas distintas. Si tu problema es que la ventana muerta dura dos segundos, necesitas la parcial. Si tu problema es que el usuario pulsa a los 900 ms y la respuesta llega a los 1400, la selectiva te lo arregla sin tocar el total.

Dónde va la frontera

En un modelo de componentes de servidor, la frontera se declara. Todo es de servidor por defecto y una directiva marca dónde empieza el cliente.

// app/producto/[id]/page.jsx  — componente de servidor
import { AnadirAlCarrito } from './anadir-al-carrito';

export default async function Pagina({ params }) {
  const producto = await db.producto(params.id);   // solo en el servidor

  return (
    <article>
      <h1>{producto.nombre}</h1>
      <Galeria imagenes={producto.imagenes} />       {/* servidor: 0 KB */}
      <Descripcion html={producto.descripcionHtml} /> {/* servidor: 0 KB */}

      {/* Unico componente de cliente: recibe dos valores primitivos */}
      <AnadirAlCarrito id={producto.id} precio={producto.precio} />

      <Resenas items={producto.resenas} />           {/* servidor: 0 KB */}
    </article>
  );
}
// anadir-al-carrito.jsx
'use client';
import { useState } from 'react';

export function AnadirAlCarrito({ id, precio }) {
  const [enviando, setEnviando] = useState(false);
  return (
    <button
      disabled={enviando}
      onClick={async () => {
        setEnviando(true);
        await fetch('/api/carrito', {
          method: 'POST',
          body: JSON.stringify({ id }),
        });
        setEnviando(false);
      }}
    >
      Anadir por {precio} euros
    </button>
  );
}

El código de Galeria, Descripcion y Resenas no llega al navegador. Ni sus dependencias: si Descripcion usa una librería de sanitización de HTML de 40 KB, esos 40 KB se quedan en el servidor.

En un modelo de islas la idea es la misma con otra sintaxis. Aquí el componente marcado es la unidad, y la directiva dice además cuándo hidratarlo:

---
import Buscador from '../componentes/Buscador.jsx';
import Carrusel from '../componentes/Carrusel.jsx';
const producto = await db.producto(Astro.params.id);
---
<article>
  <h1>{producto.nombre}</h1>
  <Buscador client:load />
  <Carrusel imagenes={producto.imagenes} client:visible />
  <div set:html={producto.descripcionHtml}></div>
</article>

En los dos casos la frontera es una frontera de serialización: las props que la cruzan tienen que poder convertirse a datos y viajar en la respuesta. Eso tiene dos consecuencias prácticas.

Lo que cruza, pesa. Pasar id y precio cuesta veinte bytes. Pasar producto entero cuesta lo que ocupe el objeto, y además duplica en el paquete de hidratación datos que ya estaban en el HTML. La disciplina es pasar lo mínimo, y a menudo eso significa pasar identificadores en vez de objetos.

Lo que cruza, tiene que ser serializable. No se pueden pasar funciones arbitrarias, clases con métodos, ni instancias con estado interno. Cuando te encuentras peleando con eso, casi siempre la señal es que la frontera está mal puesta.

Reproducción de eventos

Un mecanismo que conviene conocer porque resuelve el problema más visible de la ventana muerta.

Cuando el usuario pulsa un botón cuyo subárbol todavía no está hidratado, el framework captura el evento en la raíz —donde ya hay un delegador registrado desde muy pronto—, identifica a qué unidad de hidratación pertenece el objetivo, sube la prioridad de esa unidad, la hidrata la primera, y después reproduce el evento sobre el componente ya hidratado.

El resultado desde fuera es que el clic no se pierde: se atiende con retraso, pero se atiende. Es una mejora enorme de percepción respecto al comportamiento anterior, en el que la pulsación simplemente desaparecía.

Dos matices que hay que tener claros. Primero, esto no acorta la ventana muerta: la interacción sigue tardando lo que tarde en hidratarse esa unidad, y eso cuenta íntegro para el INP. Segundo, solo funciona para los tipos de evento que el framework delega en la raíz desde el principio; interacciones que dependen de manejadores nativos registrados por el propio componente —un pointermove sobre un lienzo, una tecla capturada en un campo concreto— no se reproducen.

De ahí sale un consejo de diseño concreto: el HTML del servidor debe ser usable sin JavaScript siempre que sea posible. Un formulario con action y method funciona antes de que llegue el paquete; un a con href navega; un details se despliega. Cada uno de esos elementos convierte la ventana muerta en un intervalo con funcionalidad degradada en vez de un intervalo sin funcionalidad. Eso no es nostalgia: es la única capa de la que puedes fiarte durante el primer segundo.

El coste se traslada a la frontera

La hidratación parcial cambia dónde está el problema, y hay que saber dónde mirar después.

El tamaño del paquete de cliente pasa a ser la suma de las islas y sus dependencias. Si tres islas distintas importan la misma librería de fechas, esa librería está una vez si el empaquetador la comparte, y tres si cada isla se compila por separado. Vigila el grafo, no las islas.

El peso de la carga de datos pasa a ser la suma de las props que cruzan. Es el número que hay que medir y que casi nadie mide. Un componente de cliente que recibe un array de doscientos objetos duplica esos doscientos objetos en la respuesta.

// Cuanto pesa lo que cruza la frontera en esta pagina.
const enLinea = Array.from(document.querySelectorAll('script:not([src])'))
  .reduce((n, s) => n + new Blob([s.textContent]).size, 0);
const documento = new Blob([document.documentElement.outerHTML]).size;
console.log(
  'datos serializados', (enLinea / 1024).toFixed(1), 'KB',
  '=', ((enLinea / documento) * 100).toFixed(0), '% del documento'
);

El número de islas tiene su propio coste. Cada una es una raíz independiente con su inicialización, su registro y su porción de tiempo de arranque. Cien islas diminutas son peores que diez razonables: por debajo de cierto tamaño, el sobrecoste por isla domina.

Una sola directiva de cliente mal colocada devuelve la aplicación entera a la hidratación completa, y nada te avisa

Este es el fallo que anula el beneficio de la hidratación parcial en más equipos de los que parece, y su mecánica merece entenderse porque es contraintuitiva. La frontera cliente-servidor no es una propiedad de un componente: es una propiedad de un subárbol. En cuanto un componente se marca como de cliente, todo lo que renderice por debajo se convierte también en cliente, porque para ejecutarse en el navegador necesita el código de sus hijos. Ahora piensa dónde suele acabar la primera directiva de cliente de un proyecto: en el layout, porque el menú de navegación tiene un desplegable; o en un proveedor de contexto, porque el tema o el idioma necesitan estado; o en el componente de análisis que hay que montar en todas las páginas. Cualquiera de esos está en la raíz, y el día que alguien lo marca, la aplicación entera vuelve silenciosamente a hidratarse completa. No hay error, no hay aviso, los tests pasan, la página funciona perfectamente. El único síntoma es que el paquete de cliente crece y la ventana muerta vuelve, y como eso ocurre en un despliegue que hacía otra cosa, nadie lo relaciona. Hay dos defensas y hacen falta las dos. La primera es de diseño: las cosas que necesitan estado en la raíz se resuelven sin marcar la raíz. Un desplegable de navegación es un details nativo, o un componente de cliente que envuelve solo al botón y su panel, no a la cabecera entera; un proveedor de tema se resuelve con una clase en el elemento raíz escrita por un script en línea de tres líneas; el análisis se carga aparte y no envuelve nada. La regla es que la frontera se empuja hacia abajo, hacia la hoja, tan lejos como se pueda, aunque cueste más ficheros. La segunda defensa es de proceso: mide el peso del paquete de cliente por ruta en cada pull request y falla el build si crece. Es el único mecanismo que detecta este problema el día que ocurre en vez de tres meses después, y es barato de montar.

⚔️ Coloca bien la frontera
  1. Dibuja el árbol de componentes de tu ruta principal y marca cuáles necesitan estado o manejadores. Calcula el porcentaje.
  2. Localiza la directiva de cliente más alta de tu aplicación y comprueba cuántos componentes arrastra por debajo.
  3. Reescribe un componente de cliente para que reciba identificadores en vez de objetos completos y mide la reducción del estado serializado.
  4. Comprueba qué elementos de tu página siguen funcionando sin JavaScript. Convierte al menos uno más.
  5. Cuenta tus islas y su tamaño medio. Si la mediana está por debajo de un par de kilobytes, plantéate agruparlas.