wandres.dev
INTEGRACIONES DE FRAMEWORK · React, Solid, Vue, Svelte

React y Preact: props, children y los límites de la hidratación

Cómo se comunica una plantilla .astro con una isla de React o Preact: el paso de props con sintaxis JSX, el contenido anidado que llega como la prop children, los slots con nombre que se convierten en props con nombre, y la frontera dura de la hidratación —que exige props serializables, prohíbe funciones y entrega los children como HTML ya impreso e inerte—. Por qué Preact es la alternativa ligera con la misma API y cuándo esa diferencia de peso decide.

⏱ 16 min

Una isla de React vive rodeada de HTML que Astro produjo en el servidor, y toda su relación con ese entorno pasa por una interfaz estrecha: recibe props y recibe contenido anidado, nada más. Lo que hace única a esa interfaz es que atraviesa una frontera física —del servidor al navegador— cuando la isla se hidrata, y esa frontera impone reglas que no existen en una app de React normal. Comprenderlas es la diferencia entre pasar datos que cruzan limpiamente y toparte con un error de serialización o con unos children que se niegan a reaccionar.

🎯 Al terminar esta lección sabrás
  • Pasar props a una isla de React o Preact con sintaxis JSX desde una plantilla .astro.
  • Entregar contenido anidado que la isla recibe como su prop children.
  • Dirigir slots con nombre de Astro hacia props con nombre en el componente de framework.
  • Reconocer los límites de la hidratación: props serializables, funciones que no cruzan y children inertes.

Pasar props a una isla

Desde una plantilla .astro, una isla de React recibe props con la misma sintaxis que cualquier componente: cadenas entre comillas y cualquier otro valor entre llaves. El componente los consume por su firma de props, exactamente como en una app de React.

---
import Ficha from '../components/Ficha.jsx';
const usuario = { nombre: 'Ada', activo: true };
---
<Ficha titulo="Perfil" nivel={3} datos={usuario} client:load />

Hasta aquí nada sorprende. La trampa está en lo invisible: cuando esa isla lleva client:load, sus props no se quedan en el servidor. Astro las serializa, las incrusta en el HTML y las vuelve a leer en el navegador para reconstruir el componente. Ese viaje de ida y vuelta es lo que impone la primera y más importante restricción del modelo: las props de una isla hidratada deben poder serializarse.

El contenido anidado llega como children

Todo lo que escribes entre la etiqueta de apertura y la de cierre de la isla se convierte en su prop children, el mismo children que conoces de React. Astro renderiza ese contenido a HTML en el servidor y se lo entrega al componente, que decide dónde colocarlo con {children}.

---
import Tarjeta from '../components/Tarjeta.jsx';
---
<Tarjeta client:visible>
  <p>Este párrafo aterriza en la prop children de la tarjeta.</p>
</Tarjeta>
// Tarjeta.jsx
export default function Tarjeta({ children }) {
  return <section class="tarjeta">{children}</section>;
}

El componente posiciona {children} donde quiera dentro de su marcado, igual que un <slot /> en un .astro. La continuidad es deliberada: el contenido anidado de Astro y el children de React son el mismo concepto visto desde dos lados de la frontera. Pero hay un matiz capital que la siguiente sección desarrolla —ese contenido no es JSX vivo, es HTML ya impreso.

Un par de detalles operativos redondean la idea. Si la isla no coloca su {children}, el contenido anidado simplemente no se muestra, exactamente como un slot por defecto que nadie usa en un .astro. Y como ese contenido lo renderiza Astro en el servidor, puede ser cualquier cosa que Astro sepa producir —Markdown, otros componentes .astro, incluso otra isla—, no solo texto: la isla receptora recibe el resultado ya convertido en HTML, indiferente a su procedencia.

Slots con nombre se vuelven props con nombre

Cuando el componente necesita varios huecos, Astro tiende un puente elegante entre su modelo de slots con nombre y el modelo de props de React: un elemento anidado con slot="nombre" llega a la isla como una prop llamada igual que el slot.

---
import Dialogo from '../components/Dialogo.jsx';
---
<Dialogo client:load>
  <span slot="titulo">Confirmar</span>
  <p>Cuerpo del diálogo en el slot por defecto.</p>
  <div slot="pie">Acciones aquí</div>
</Dialogo>
// Dialogo.jsx recibe titulo y pie como props, mas children
export default function Dialogo({ titulo, pie, children }) {
  return (
    <div class="dialogo">
      <header>{titulo}</header>
      <main>{children}</main>
      <footer>{pie}</footer>
    </div>
  );
}

El span con slot="titulo" no va al children, sino que aterriza en la prop titulo; lo mismo el pie. El contenido sin marcar cae en children, como el slot por defecto. Así, el redactor de la plantilla .astro piensa en slots con nombre, y el autor del componente de React piensa en props con nombre, sin que ninguno tenga que adaptarse al vocabulario del otro. Astro traduce entre los dos modelos en la frontera.

Ese mapeo funciona con toda limpieza para nombres simples. Conviene, eso sí, mantener los nombres de slot sencillos y verificar en el componente cómo llegan, porque la traducción del vocabulario de slots de Astro al de props de JavaScript no siempre es literal cuando entran guiones u otros caracteres. Regla de diagnóstico: ante un hueco que no aparece donde esperabas, sospecha primero del nombre del slot antes que de la lógica del componente.

🔤

Props serializables

Cadenas, números, booleanos, arrays y objetos planos cruzan sin problema; también Date, Map, Set, URL y BigInt gracias al serializador de Astro.

🚫

Lo que no cruza

Funciones, instancias de clase con métodos, símbolos y elementos JSX pasados como prop no sobreviven al viaje al cliente.

🧷

children como HTML

El contenido anidado se imprime en el servidor y llega inerte: la isla lo coloca, pero no lo vuelve a renderizar con su estado.

La frontera de la hidratación

Aquí se concentra lo que separa una isla de un componente de React corriente, y merece precisión. Hay dos límites distintos que suelen confundirse.

El primero es la serialización de props. Como los valores viajan del servidor al navegador convertidos en datos, solo cruzan los que son representables como datos. El serializador de Astro es más generoso que un JSON.stringify —admite Date, Map, Set, URL, BigInt y algún tipo más—, pero no puede transportar comportamiento: una función pasada como prop no tiene representación serializable, así que no llega, y con ella se cae la idea de pasar callbacks a una isla desde la plantilla.

---
import Boton from '../components/Boton.jsx';
---
<!-- ERROR conceptual: la funcion no cruza al cliente -->
<Boton onClick={() => console.log('hola')} client:load />

Una isla hidratada define sus manejadores de eventos dentro del componente de framework, no los recibe desde Astro. La plantilla le pasa datos —qué mostrar, con qué configuración—, pero el comportamiento vive del lado del cliente, escrito en React. Esa división no es una carencia: es la consecuencia directa de que la interactividad ocurre en el navegador, donde las funciones existen, y la plantilla se ejecutó antes, en el servidor, donde ya no.

El segundo límite es la naturaleza inerte de los children. El contenido que pasas a una isla se renderiza en el servidor y llega al cliente como HTML ya cocido, envuelto en un marcador que React adopta pero no vuelve a evaluar. La isla puede situar ese {children} donde quiera, pero no puede regenerarlo cuando cambia su estado: si esperas que un hijo se repinte al pulsar un botón de la isla, descubrirás que permanece fijo, porque nunca fue JSX vivo, solo una foto. Cuando necesitas hijos verdaderamente reactivos, deben nacer dentro del componente de framework, no entrar por la puerta de los slots.

flowchart LR
TPL[plantilla astro] --> PROPS[props serializadas]
TPL --> CH[contenido anidado a HTML]
PROPS --> BORDE[frontera servidor cliente]
CH --> BORDE
BORDE --> ISLA[isla de react hidratada]
FN[funcion como prop] -.no cruza.-> BORDE
ISLA --> EV[eventos definidos dentro del componente]
style BORDE fill:#f9e2af,color:#11111b
style ISLA fill:#89b4fa,color:#11111b
style FN fill:#f38ba8,color:#11111b
💡
Preact: la misma API con una fracción del peso

Preact reproduce la interfaz de React —children, hooks, JSX— en un runtime muchísimo más pequeño, del orden de unos pocos kilobytes frente a las decenas de React. Para islas modestas, un contador, un menú, una pestaña, esa diferencia de peso es JavaScript que tus visitantes no descargan. La integración @astrojs/preact incluso ofrece una capa de compatibilidad para reutilizar librerías del ecosistema de React. La regla práctica: si tus islas no dependen de paquetes atados a React, Preact te da la misma ergonomía por una parte del coste, y en un sitio de islas ese coste se paga isla a isla.

La interfaz de una isla es un contrato de datos, no de comportamiento

La frontera de la hidratación parece una lista de restricciones molestas —props que deben serializarse, funciones que no cruzan, hijos que no reaccionan—, pero mirada de frente es una de las ideas más limpias del desarrollo web moderno, y conviene internalizarla como principio antes que como norma. En una aplicación de React clásica, la comunicación entre componentes es un continuo: pasas datos, funciones, elementos, referencias, cualquier valor de JavaScript, porque todo vive en el mismo espacio de memoria del navegador. Astro parte ese continuo en dos mitades separadas por una frontera física —el servidor, que corre una vez y termina, y el cliente, que empieza después— y esa separación convierte la comunicación en algo cualitativamente distinto: ya no compartes memoria, envías mensajes. Y un mensaje solo puede transportar datos, nunca comportamiento, porque el comportamiento es código que debe existir en el lado que lo ejecuta. Por eso una función no cruza: no es un valor que se copie, es una capacidad que pertenece a un espacio concreto. Por eso los children llegan inertes: fueron renderizados en un lado y solo su rastro, el HTML, viaja al otro. Reconocer esto reordena tu forma de diseñar la interfaz de cada isla. Dejas de preguntarte qué le paso al componente y empiezas a preguntarte qué datos necesita para reconstruirse a sí mismo en el cliente, porque eso —y solo eso— es lo que puede recibir. La props de una isla se convierte así en un contrato de datos, semejante a la carga útil de una petición de red: serializable, autosuficiente, sin dependencias de un contexto que no viaja. Esta disciplina, que Astro te impone por su arquitectura, resulta ser buena higiene incluso donde no es obligatoria, porque empuja a que cada pieza interactiva sea explícita sobre su entrada y no dependa de referencias invisibles al entorno que la creó. La lección es más ancha que Astro: cada vez que un sistema se parte en procesos, máquinas o momentos distintos, la comunicación entre las partes deja de ser compartir estado y pasa a ser intercambiar mensajes, y el primer deber del ingeniero es saber siempre de qué lado de la frontera está cada cosa.

⚔️ Cruza la frontera con datos, no con funciones
  1. Crea una isla Ficha.jsx que reciba props titulo, nivel y un objeto datos; pásaselos con client:load y confirma en el navegador que llegan intactos tras la hidratación.
  2. Anida contenido dentro de la isla y muéstralo con {children}; luego pásale además un slot="pie" y recíbelo como prop con nombre.
  3. Intenta pasar una función como prop a la isla y observa el fallo; después mueve ese manejador al interior del componente de React y verifica que ahora sí responde.
  4. Pasa como children un bloque que dependa de cambiar con el estado de la isla; comprueba que no se repinta y razona por qué un hijo servido por Astro llega inerte.