dehydrate e hydrate en TanStack Query
Las tres lecciones anteriores dejaron planteado el problema en abstracto: el servidor calcula, el cliente necesita heredar, y el estado tiene que cruzar un cable sin mezclarse entre usuarios. TanStack Query resuelve ese problema con una API deliberadamente pequeña —un cliente creado por petición, una precarga, `dehydrate` en el servidor y `hydrate` en el cliente— cuya sencillez esconde decisiones de diseño que conviene entender antes de usarla. Esta lección recorre el mecanismo completo: por qué el cliente de consultas es exactamente el singleton prohibido de la lección anterior y cómo se construye por petición sin recrearlo en cada render del navegador, qué extrae realmente `dehydrate` de la cache y en qué forma viaja, cómo la frontera de hidratación fusiona esos datos en la cache del cliente sin destruir lo que ya hubiera, y cuál es el criterio para decidir qué consultas merecen viajar. Cierra con el detalle que gobierna todo el comportamiento posterior: la marca temporal que acompaña a cada dato y que determina si el cliente vuelve a pedirlo nada más llegar.
Hay un momento revelador la primera vez que se conecta TanStack Query con renderizado en servidor: descubrir que la librería no inventa ningún concepto nuevo para el SSR. Todo lo que hace es aplicar con rigor las tres ideas que ya has visto por separado. Su cache es un estado que vive en memoria, así que en el servidor debe ser por petición o filtrará datos entre usuarios. Ese estado tiene que llegar al cliente, así que debe deshidratarse a una forma serializable y volver a inflarse al otro lado. Y el cliente, al recibirlo, no puede limitarse a mostrarlo: tiene que saber cuándo se obtuvo, para decidir si sigue siendo válido o si debe pedirlo otra vez. Lo elegante del diseño es que esas tres respuestas caben en cuatro piezas y que ninguna de ellas oculta lo que hace. El servidor precarga las consultas que la primera pantalla necesita, deshidrata la cache resultante a una estructura plana con sus claves, sus datos y sus marcas de tiempo, y el cliente la fusiona en su propia cache antes del primer render. El resultado, visto desde fuera, es una pantalla que aparece completa y ya no vuelve a parpadear: el navegador no pide lo que el servidor ya trajo, porque para él ese dato no es nuevo, es un dato que ya estaba en cache. Toda la lección consiste en entender por qué eso funciona y qué hay que vigilar para que siga funcionando.
- Construir el cliente de consultas por petición en el servidor y como instancia estable en el navegador.
- Precargar consultas, deshidratar la cache resultante y fusionarla en el cliente mediante la frontera de hidratación.
- Decidir con criterio qué consultas viajan, usando el filtro de deshidratación y el umbral de frescura.
- Explicar el papel de la marca temporal de cada consulta en la revalidación inmediata tras hidratar.
El cliente de consultas es exactamente el singleton prohibido
Un cliente de consultas es una cache en memoria indexada por clave. Eso lo convierte, palabra por palabra, en el objeto que la lección anterior prohibía colocar en el ámbito de un módulo del servidor: guarda datos que dependen de quién pregunta, y compartirlo entre peticiones significa servir a un usuario la respuesta privada de otro. La regla, por tanto, es doble y asimétrica, y es la fuente de la mayoría de los errores de integración.
En el servidor, una instancia nueva por petición, siempre, sin excepción ni optimización. En el navegador, exactamente lo contrario: una única instancia estable que sobreviva a todos los renders, porque si se recrea en cada render la cache se vacía continuamente y la librería pierde su razón de ser. La forma habitual de conseguir ambas cosas con un solo módulo es una función que devuelva siempre una instancia nueva en el servidor y memorice una instancia perezosa en el navegador.
// Servidor: instancia nueva. Navegador: instancia unica y estable.
let clienteNavegador: QueryClient | undefined
export function obtenerCliente() {
const crear = () =>
new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
})
if (typeof window === 'undefined') return crear() // por peticion
return (clienteNavegador ??= crear()) // una sola vez
}
El umbral de frescura declarado por defecto no es un detalle cosmético en este contexto. Con el valor cero que trae la librería de fábrica, todo dato hidratado se considera obsoleto en el instante en que llega, y el cliente lo vuelve a pedir nada más montar: habrías pagado el coste de deshidratar para no ahorrar ni una petición. Un umbral positivo es lo que hace que el trabajo del servidor sirva de algo.
Merece la pena entender por qué la instancia del navegador debe memorizarse de forma perezosa y no crearse al importar el módulo. El módulo del cliente también se evalúa en el servidor durante el renderizado, porque es el mismo archivo; crear la instancia en el ámbito superior la construiría también allí, y volveríamos exactamente al singleton compartido entre peticiones. La comprobación diferida no es una micro-optimización sino la que mantiene separadas las dos reglas contrarias dentro de un único módulo isomorfo.
Precargar, deshidratar, fusionar
El flujo del servidor tiene tres pasos y ninguno es opcional. Se crea el cliente de la petición, se esperan las consultas que la primera pantalla necesita, y se deshidrata la cache resultante. El punto clave es la espera: si no se aguarda a que las consultas se resuelvan, la cache se deshidrata vacía y el HTML sale sin datos.
// SERVIDOR: precargar, esperar, deshidratar
const cliente = obtenerCliente()
await Promise.all([
cliente.prefetchQuery({ queryKey: ['articulos'], queryFn: traerArticulos }),
cliente.prefetchQuery({ queryKey: ['perfil', id], queryFn: () => traerPerfil(id) }),
])
const estado = dehydrate(cliente) // claves, datos y marcas de tiempo
Lo que dehydrate produce no es la cache sino su retrato: una estructura plana con la lista de consultas resueltas, cada una con su clave normalizada, sus datos y los metadatos temporales que permiten juzgar su frescura al llegar. No viaja ninguna función, ningún observador, ninguna suscripción; todo eso se reconstruye en el cliente. Esa estructura se serializa con las reglas de la primera lección y se incrusta en el documento.
En el cliente, la frontera de hidratación recibe ese retrato y lo inserta en la cache del cliente estable antes de que los componentes de su interior se rendericen por primera vez. La operación es una fusión, no un reemplazo: las consultas que ya existieran en la cache y sean más recientes se conservan, lo que importa cuando se navega entre páginas y cada una trae su propio bloque de datos deshidratados.
Que la fusión respete lo más reciente no es un detalle de implementación sino el requisito que hace segura la navegación del lado del cliente. Imagina que el usuario edita algo, la cache local queda con el valor nuevo, y a continuación navega a una página cuyo HTML deshidratado se generó antes de esa edición. Un reemplazo ciego devolvería la interfaz al pasado sin que nadie hubiera pedido nada; la fusión por marca temporal descarta lo viejo y conserva lo que el cliente ya sabía.
// CLIENTE: la frontera fusiona el estado antes del primer render
<QueryClientProvider client={obtenerCliente()}>
<HydrationBoundary state={estadoDeshidratado}>
<Pagina />
</HydrationBoundary>
</QueryClientProvider>
flowchart TD P[peticion entrante] --> C[cliente de consultas nuevo para esta peticion] C --> F[prefetch de las consultas de la primera pantalla] F --> D[dehydrate: claves, datos y marcas de tiempo] D --> HT[serializado e incrustado en el HTML] HT --> B[frontera de hidratacion en el navegador] B --> M[fusion en la cache del cliente estable] M --> V[los componentes leen de cache y no piden nada] style V fill:#a6e3a1,color:#11111b
Qué merece viajar y qué debe quedarse
Deshidratar la cache entera es cómodo y casi siempre equivocado. Cada consulta añadida engorda el HTML, retrasa el primer pintado y expone su contenido a cualquiera que abra el código fuente. El criterio útil es la pregunta que ya conoces de la primera lección, aplicada consulta a consulta: sin este dato, la primera pantalla parpadea.
Merece viajar
Lo que la primera pantalla muestra por encima del pliegue y depende de datos remotos: el artículo que se está leyendo, la lista principal, la cabecera con el nombre del usuario.
No debe viajar
Lo que se abre bajo demanda —un panel lateral, un modal, la segunda pestaña—, lo que cambia cada pocos segundos y lo que contiene información que el usuario no debería ver en el fuente.
Filtro explícito
La opción de deshidratación permite decidir consulta a consulta si se lleva. Úsala para excluir por clave, por estado o por tamaño, en lugar de confiar en que la cache solo contenga lo justo.
Errores y pendientes
Por defecto solo se deshidratan las consultas resueltas con éxito. Deshidratar un error convierte un fallo transitorio del servidor en un fallo persistente del cliente, que ya no reintentará por su cuenta.
La clave de cada consulta viaja en el HTML junto a sus datos. Si construyes claves con identificadores internos, tokens o fragmentos de la sesión, estarás publicando esa información aunque los datos asociados sean inocuos. Trata las claves como parte de la superficie expuesta: deben ser estables, serializables y libres de secretos, igual que los datos que indexan.
El desfase temporal y la revalidación al llegar
Todo dato deshidratado nace con una edad. El servidor lo obtuvo en un instante, el HTML tardó en generarse y viajar, el navegador tardó en descargar y ejecutar el JavaScript, y solo entonces se hidrata. Entre la consulta y el primer render del cliente ha pasado un tiempo que puede ir de decenas de milisegundos a minutos enteros si hay una cache de páginas por delante.
Por eso la marca temporal que acompaña a cada consulta deshidratada es la pieza que gobierna todo el comportamiento posterior. Al hidratar, el cliente no asume que el dato acaba de llegar: compara esa marca con el umbral de frescura configurado y decide. Si el dato aún está dentro del umbral, no pide nada y la pantalla queda quieta. Si ya lo ha superado, muestra de inmediato lo hidratado y lanza una revalidación en segundo plano, de modo que el usuario ve contenido al instante y la corrección llega después sin pantalla vacía.
Esa combinación es lo que hace que el patrón sea robusto ante el desfase: no promete que el dato sea actual, promete que será instantáneo y que se corregirá solo. La configuración del umbral es, por tanto, una declaración explícita de cuánta obsolescencia tolera cada dato, y merece pensarse por consulta y no de forma global.
// El umbral se declara por consulta segun lo que cada dato tolera
useQuery({ queryKey: ['articulo', id], queryFn: traer, staleTime: 300_000 })
useQuery({ queryKey: ['saldo'], queryFn: traer, staleTime: 0 })
Un artículo publicado tolera minutos de obsolescencia sin que nadie lo note; un saldo o un contador de existencias no tolera ninguno y debe revalidarse en cuanto el cliente pueda, aunque se hidrate. El umbral cero no invalida el deshidratado en ese segundo caso: el usuario sigue viendo el valor del servidor de inmediato, y la revalidación ocurre por detrás sin pantalla vacía. Lo que cambia es que se acepta pagar una petición extra a cambio de exactitud.
La forma más productiva de entender este mecanismo es dejar de verlo como un transporte de datos y empezar a verlo como un transporte de trabajo completado. Lo que el servidor le manda al cliente no es simplemente el contenido de unos artículos: es la afirmación de que la pregunta identificada por esta clave ya fue formulada, que se resolvió en tal instante, y que su respuesta es esta. Los datos son el resultado; la clave y la marca de tiempo son lo que convierte ese resultado en conocimiento reutilizable en lugar de en un montón de bytes anónimos. De ahí se sigue todo lo demás, y de forma casi obligada. La clave debe viajar, porque sin ella el cliente no puede reconocer que la pregunta que está a punto de hacer ya tiene respuesta. La marca de tiempo debe viajar, porque sin ella el cliente no puede juzgar si esa respuesta sigue valiendo y tendría que elegir entre confiar ciegamente o desconfiar siempre, que son los dos extremos malos. Y la fusión debe ser una fusión y no un reemplazo, porque el cliente puede tener conocimiento más reciente que el que llega del servidor y perderlo sería retroceder en el tiempo. Visto así, dehydrate e hydrate dejan de ser dos funciones de una librería y se revelan como la implementación de una idea general que reaparece en toda la computación distribuida: cuando dos partes de un sistema pueden resolver la misma pregunta, lo valioso no es mover la respuesta sino mover la prueba de que la pregunta ya fue resuelta, con su identidad y su momento. Esa prueba es lo que permite al receptor no repetir el trabajo, y es exactamente la razón de que el usuario deje de ver el parpadeo.
- Monta el patrón completo: cliente por petición en el servidor, instancia estable en el navegador, precarga, deshidratación y frontera de hidratación.
- Fija el umbral de frescura en cero y observa en la pestaña de red que el cliente vuelve a pedir todo lo hidratado. Súbelo después y comprueba que las peticiones desaparecen.
- Elimina la espera de las precargas y verifica que el estado deshidratado llega vacío. Explica por qué el HTML sigue viéndose bien en algunos casos y mal en otros.
- Añade un filtro de deshidratación que excluya una consulta pesada y compara el peso del HTML antes y después.
- Fuerza un fallo en una de las consultas del servidor y comprueba qué llega al cliente y qué hace este a continuación.
- Introduce un retraso artificial de varios minutos entre la precarga y la hidratación, y describe qué decide el cliente con la marca temporal y qué ve el usuario mientras tanto.