Cuando los desarrolladores pasan de React o Next.js a Astro, recurren a una isla de framework en el primer elemento interactivo que construyen. Un desplegable se convierte en un componente de React con client:load. La razón para no tener siempre islas es que la hidratación es cara. Esta publicación explica por qué y le ofrece un orden de opciones que conviene revisar antes de entregar un componente al navegador.
Requisitos previos
Necesitará lo siguiente:
- Conocimientos básicos de los componentes de Astro
- Node.js 22 o posterior
Por qué la hidratación es cara
Una directiva de cliente le indica a Astro que envíe un componente de framework de UI al navegador y lo hidrate allí, y:
- Ejecuta su componente dos veces: El servidor ya renderizó el HTML. La hidratación descarga el runtime del framework y el componente, y luego ejecuta el componente de nuevo en el navegador para reconstruir su estado y adjuntar los escuchadores de eventos a un marcado que ya existe.
- Bloquea el hilo principal: La hidratación es trabajo síncrono que compite con los primeros clics y toques del usuario. Es el trabajo que aparece como Interaction to Next Paint (INP) en el campo.
- Escala con el árbol de componentes, no con la interactividad: Una isla grande con un solo botón hidrata igualmente todo el árbol.
Ahora quizá piense en client:idle y client:visible, pero estos solo aplazan el runtime y el segundo renderizado. No eliminan las operaciones caras que conlleva la hidratación.
Los componentes no son islas
Una confusión habitual es tratar cada componente como una isla. Dividir una página en <Header />, <Posts /> y <Footer /> es componentización. Son archivos .astro, se renderizan a HTML en el servidor y no llevan JavaScript.
Una isla es algo más concreto: un componente de framework de UI (React, Vue, Svelte, etc.) que lleva una directiva client:*. Un componente .astro no puede aceptar una directiva de cliente, ya que nunca se hidrata.
Un orden de opciones antes de hidratar
Antes de añadir una directiva de cliente, repase esta lista:
- HTML estático: ¿Puede esto renderizarse en el servidor y no cambiar nunca por usuario?
- Datos resueltos en tiempo de compilación: ¿El valor solo necesita estar actualizado a fecha del último despliegue? Obténgalo durante la compilación e incrústelo en el HTML.
- Una isla de servidor: ¿El valor necesita estar fresco en cada solicitud, como un contador de carrito o un nombre de usuario con sesión iniciada? Renderícelo en el servidor con
server:defery mantenga el resto de la página cacheable. - Un script con mejora progresiva: ¿Es un comportamiento del DOM de una sola vez, como un interruptor, un diálogo o un cambio de pestaña? Renderice el marcado funcional en el servidor y adjunte un script pequeño.
- Una isla hidratada: ¿Necesita un estado del lado del cliente continuo del que muchas partes de la UI derivan y al que reaccionan? Ahora un framework es absolutamente necesario, y conviene hidratar el componente más pequeño que contenga ese estado.
Veamos algunos enfoques de la lista en detalle.
Datos resueltos en tiempo de compilación
Si un valor solo necesita estar actualizado a fecha de su último despliegue, resuélvalo durante la compilación. Un contador de visitas por publicación es el caso clásico para el que la gente construye una isla.
La vía de la isla: Un componente de framework que obtiene el número en el navegador después de hidratarse.
---import ViewCount from '../components/ViewCount.jsx'const { slug } = Astro.params---
<ViewCount slug={slug} client:load />import { useEffect, useState } from 'react'
export default function ViewCount({ slug }) { const [views, setViews] = useState(null)
useEffect(() => { fetch(`/api/views?slug=${slug}`) .then((res) => res.json()) .then((data) => setViews(data.views)) }, [slug])
return <span>{views === null ? 'Loading...' : `${views} views`}</span>}Sí, todos estamos acostumbrados a usar React para esto y quizá sea la opción más fácil a la que recurrir. Pero para todo un sitio web que tiene pocos componentes dinámicos, ¿tiene siquiera sentido cargar todo el runtime de React y el componente, hidratar al cargar y luego hacer una solicitud desde el navegador de cada visitante para obtener las visitas?
La vía del tiempo de compilación: Obtenga el valor durante la compilación e incruste el número en el HTML.
---// Se ejecuta en tiempo de compilación para una página estática. El número forma parte del HTML.import { getViews } from '../lib/analytics'
const { slug } = Astro.propsconst views = await getViews(slug)---
<span>{views} views</span>Ahora puede reconstruir esto de forma programada, por ejemplo con un hook de despliegue diario, y el conteo se mantiene suficientemente actualizado para la mayoría del contenido.
Qué cambia entre las dos:
- JavaScript: el runtime de React más el componente vs. ninguno.
- Red al visitar: una solicitud desde cada visitante vs. ninguna.
- Primer renderizado (first paint): un estado de carga que se resuelve tarde vs. el número ya en el HTML.
Lo único que renuncia es a la frescura. El número de tiempo de compilación solo está tan actualizado como su última compilación. Para un contador de visitas eso está bien, y se ahorra el framework por completo.
Una isla de servidor para datos por solicitud
A veces el valor tiene que ser correcto en cada solicitud, como un contador de carrito o un nombre con sesión iniciada. Incrustar en tiempo de compilación no ayuda ahí, así que el reflejo es un componente de cliente que obtiene los datos al montarse, lo que le devuelve directamente a enviar un runtime y un estado de carga.
Una isla de servidor le da datos frescos sin nada de eso. Renderiza el componente en el servidor, de forma diferida, para que la página estática que lo rodea siga siendo cacheable.
---import { getCartCount } from '../lib/cart'
const count = await getCartCount(Astro.request)---
<span class="cart-count">{count}</span>---import CartCount from '../components/CartCount.astro'---
<CartCount server:defer> <span slot="fallback">0</span></CartCount>Astro renderiza primero la carcasa estática, muestra el fallback y luego rellena la isla desde el servidor. La carcasa se puede cachear de forma agresiva, el contador es correcto en cada solicitud y el navegador recibe HTML sin ningún runtime de framework adjunto. En comparación con la obtención en el cliente, conserva los datos frescos y elimina el JavaScript.
Mejora progresiva con un script
Para un comportamiento del DOM de una sola vez, como un interruptor o un cambio de pestaña, no necesita la reactividad de un framework. Necesita un escuchador de eventos. Astro procesa las etiquetas <script> por usted, las empaqueta y las añade como módulos, de modo que un script puede ejecutarse en el navegador sin arrastrar React o Svelte.
La vía de la isla: Pestañas como componente de React, hidratado al cargar.
import { useState } from 'react'
const tabs = ['Overview', 'Pricing', 'FAQ']
export default function Tabs() { const [active, setActive] = useState(0)
return ( <div> <div role="tablist"> {tabs.map((label, index) => ( <button key={label} role="tab" aria-selected={index === active} onClick={() => setActive(index)} > {label} </button> ))} </div> {tabs.map((label, index) => ( <div key={label} role="tabpanel" hidden={index !== active}> <p>{label} content</p> </div> ))} </div> )}Usado con <Tabs client:load />, esto envía el runtime de React y el componente, y luego hidrata todo el conjunto para gestionar un único estado: qué pestaña está abierta.
La vía del script: Las mismas pestañas como HTML renderizado en el servidor con un script adjunto.
---const tabs = ['Overview', 'Pricing', 'FAQ']---
<div data-tabs> <div role="tablist"> { tabs.map((label, index) => ( <button role="tab" id={`tab-${index}`} aria-controls={`panel-${index}`} aria-selected={index === 0 ? 'true' : 'false'} > {label} </button> )) } </div>
{ tabs.map((label, index) => ( <div role="tabpanel" id={`panel-${index}`} aria-labelledby={`tab-${index}`} hidden={index !== 0} > <p>{label} content</p> </div> )) }</div>
<script> function setupTabs() { document.querySelectorAll('[data-tabs]').forEach((root) => { if (root.dataset.ready) return root.dataset.ready = 'true'
const buttons = root.querySelectorAll('[role="tab"]') buttons.forEach((button) => { button.addEventListener('click', () => { buttons.forEach((other) => { const selected = other === button other.setAttribute('aria-selected', String(selected)) const panelId = other.getAttribute('aria-controls') const panel = panelId ? document.getElementById(panelId) : null if (panel) panel.hidden = !selected }) }) }) }) }
setupTabs() document.addEventListener('astro:page-load', setupTabs)</script>Qué cambia entre las dos:
- JavaScript: el runtime de React más el componente vs. unas pocas líneas de script empaquetado.
- Segundo renderizado: la isla vuelve a ejecutar el componente en el navegador para adjuntar
onClickvs. el script adjunta escuchadores al marcado. - Antes de que cargue JavaScript: la primera pestaña es visible y los roles ARIA están establecidos en ambos, pero la versión con script sigue funcionando como contenido plano si el script falla.
Dado que los scripts de módulo empaquetados de Astro se ejecutan una sola vez, con el enrutamiento del lado del cliente (el <ClientRouter />) un script de nivel superior no se vuelve a ejecutar tras la navegación y sus escuchadores dejan de funcionar. Vincularse a astro:page-load vuelve a ejecutar la configuración en cada navegación, y la protección data-ready evita que el mismo elemento se vincule dos veces. Este patrón cubre la mayoría de lo que la gente construye con islas: acordeones, desplegables, diálogos, botones de copiar y formularios.
Cuándo la hidratación es la opción correcta
Saltarse las islas debería ser lo predeterminado, pero cuando la interacción tiene un estado del lado del cliente real del que muchas partes de la UI derivan y al que reaccionan con el tiempo, conviene usar islas:
- Un formulario donde los campos dependen entre sí y la UI se actualiza mientras el usuario escribe.
- Una vista que recibe actualizaciones por un WebSocket y se vuelve a renderizar con frecuencia.
- Un canvas, arrastrar y soltar, o una superficie de gráficos con un estado interno pesado.
- Un widget de terceros de React o Vue existente que prefiere insertar antes que reescribir.
Cuando hidrate, hágalo con:
- Hidrate la hoja, no el árbol: Ponga la directiva en el componente más pequeño que contenga el estado. Mantenga como
.astrolas partes estáticas a su alrededor. - Aplace cuando pueda:
client:visibleyclient:idleempujan el coste más allá del primer renderizado para todo lo que está por debajo del pliegue. - Vigile lo que pasa como props: Astro serializa las props de la isla en el HTML para que el cliente pueda rehidratar con los mismos valores. Pasar un objeto o array grande infla la página para cada visitante. Pase ids y primitivos, y obtenga el resto dentro de la isla o de una isla de servidor.
- Evite
client:onlypara contenido: Se salta el renderizado en el servidor por completo, por lo que el componente está vacío en el HTML inicial. Eso es un problema para todo lo que sí quiere que se indexe.
Conclusión
Astro no envía JavaScript de forma predeterminada, y la hidratación es el punto en el que puede volver a optar por el coste de un framework en el navegador. Eso es caro: segundo renderizado, tiempo de hilo principal y una carga útil que crece con el tamaño del componente.
Si tiene alguna pregunta o comentario, no dude en contactarme en Twitter.