Patrones de composición a escala
Cómo se construye una interfaz que crece sin enredarse: un design system de primitivas componibles, slots que reciben contenido en lugar de props que configuran, la frontera de la isla que separa el HTML de servidor de la interactividad hidratada, y límites de responsabilidad claros entre componentes que presentan y componentes que deciden.
A pequeña escala, un componente se hace de una pieza: toda su marca, su lógica y su interactividad caben en un fichero y nadie sufre. A escala, esa comodidad se vuelve veneno: componentes que lo hacen todo se copian, divergen y se pudren, y una interfaz de trescientas piezas construida así es imposible de mantener coherente. La salida no es hacer componentes más grandes sino más pequeños y mejor combinables, y esa es toda la disciplina de este capítulo. Vamos a ver cómo un design system se sostiene sobre primitivas que se componen con slots, cómo la frontera de la isla decide qué se hidrata y qué no, y cómo repartir responsabilidades para que cada pieza haga una sola cosa. La composición no es un adorno de la arquitectura de UI: es la arquitectura de UI.
- Construir un design system de primitivas componibles en vez de componentes monolíticos.
- Preferir slots que reciben contenido a props que configuran variantes sin fin.
- Trazar la frontera de la isla entre el HTML de servidor y la interactividad hidratada.
- Separar responsabilidades entre componentes que presentan y componentes que deciden.
Primitivas componibles, no componentes monolíticos
Un design system que escala no es un catálogo de componentes terminados sino un juego de piezas que se combinan. En la base están los tokens —color, espacio, tipografía— y sobre ellos, las primitivas: el botón, la tarjeta, el campo, la pila. Una primitiva no sabe de tu dominio; no conoce el checkout ni la cuenta, solo resuelve una necesidad visual genérica y deja huecos para que otros la llenen. Esa ignorancia es lo que la hace reutilizable: cuanto menos sabe una pieza, en más sitios sirve.
La tentación contraria es la explosión de props: una tarjeta que, para cubrir cada caso, acumula titulo, subtitulo, imagen, badge, accionPrimaria, accionSecundaria, hasta convertirse en un formulario de veinte parámetros que nunca cubre el caso veintiuno. El antídoto es invertir el control: en lugar de configurar la pieza con datos, deja que quien la usa le pase contenido. Ahí entran los slots.
---
// packages/ui/src/Card.astro — una primitiva sin dominio
interface Props { padding?: 'sm' | 'md' | 'lg'; }
const { padding = 'md' } = Astro.props;
---
<article class:list={['card', `p-${padding}`]}>
<header><slot name="titulo">Sin titulo</slot></header>
<div class="cuerpo"><slot /></div>
<footer><slot name="acciones" /></footer>
</article>
Esta tarjeta apenas tiene una prop de estilo; todo lo demás son huecos. El <slot /> sin nombre recibe el cuerpo; los slots con nombre —titulo, acciones— reciben partes concretas; y el texto dentro de <slot name="titulo"> es el fallback que se muestra si nadie llena ese hueco. La pieza no decide qué va dentro: ofrece una forma y cede el contenido a quien la compone.
Slots: recibir contenido en vez de configurarlo
La diferencia entre props y slots parece técnica y es filosófica. Una prop dice qué valor toma un parámetro; un slot dice qué contenido ocupa un lugar. Cuando lo que varía es un dato —un tamaño, un booleano, una URL—, una prop es lo correcto. Pero cuando lo que varía es estructura —qué elementos van dentro, cómo se anidan, qué componentes se colocan ahí—, forzarlo en props es pelear contra la corriente. Los slots dejan que la composición ocurra donde debe: en el punto de uso.
---
// src/features/catalogo/TarjetaProducto.astro — compone la primitiva
import Card from '@repo/ui/Card.astro';
import Precio from './Precio.astro';
import BotonComprar from './BotonComprar.tsx';
const { producto } = Astro.props;
---
<Card padding="lg">
<span slot="titulo">{producto.nombre}</span>
<p>{producto.descripcion}</p>
<Precio valor={producto.precio} />
<BotonComprar id={producto.id} slot="acciones" client:visible />
</Card>
Fíjate en el reparto: la primitiva Card es de UI pura y vive en el paquete compartido; la TarjetaProducto es de dominio y vive en su feature, y su único trabajo es componer —llenar los huecos de la primitiva con piezas del dominio—. La misma tarjeta genérica sirve para un producto, un artículo o un perfil, porque no sabe de ninguno; quien sabe es quien la compone. Este patrón —primitivas tontas abajo, composición con conocimiento de dominio arriba— es el que mantiene coherente una interfaz enorme sin duplicar el marco visual en cada caso.
La heurística que evita el 90% de los malos componentes: si estás a punto de añadir una prop cuyo valor es JSX, HTML o un componente, para y conviértela en un slot. Las props son para datos serializables —cadenas, números, banderas—; los slots son para contenido y estructura. Un componente que recibe su cabecera como una cadena titulo es rígido; uno que la recibe por un slot titulo acepta un texto, un texto con un icono, o dos líneas con un enlace, sin cambiar una coma. Cuando dudes entre una prop más o un slot, casi siempre la respuesta correcta es el slot.
La frontera de la isla: servidor contra interactividad
En Astro todo componente .astro es, por defecto, de servidor: se ejecuta en el build o en la petición, produce HTML y no envía ni un byte de JavaScript al navegador. La interactividad llega solo cuando insertas un componente de framework —React, Solid, Vue, Svelte— y lo marcas con una directiva de cliente para que se hidrate. Ese punto donde un componente pasa de HTML inerte a JavaScript vivo es la frontera de la isla, y saber dónde trazarla es la decisión de rendimiento más importante de toda la interfaz.
La regla que escala es empujar esa frontera lo más abajo posible en el árbol: que las islas sean las hojas, no las ramas. Si el BotonComprar del ejemplo necesita estado, hidrata solo el botón —una isla diminuta— y deja que todo lo que lo rodea —la tarjeta, el precio, la descripción— siga siendo HTML de servidor sin coste. El error opuesto, hidratar un contenedor grande porque algo dentro es interactivo, arrastra a la hidratación todo lo que cuelga de él y tira por la borda la ventaja de Astro.
---
// una isla pequeña con carga diferida, y una server island
import Contador from './Contador.tsx';
import Recomendaciones from './Recomendaciones.astro';
---
<Contador inicial={0} client:visible />
<Recomendaciones server:defer>
<p slot="fallback">Cargando recomendaciones...</p>
</Recomendaciones>
Las directivas gradúan cuándo se hidrata: client:load de inmediato, client:idle cuando el navegador queda libre, client:visible al entrar en pantalla, client:only sin renderizar en servidor. Y las server islands cubren el caso inverso: un trozo que es dinámico pero no interactivo —recomendaciones personalizadas, un dato fresco— se marca con server:defer, se renderiza aparte en el servidor tras servir la página, y mientras tanto muestra el contenido de su slot fallback. Así una página estática y cacheable puede tener islas dinámicas sin volverse dinámica entera.
Hay una restricción que dicta dónde puedes trazar la frontera: las props que cruzan hacia una isla han de ser serializables. Astro las convierte a un formato que el navegador reconstruye, así que valen cadenas, números, objetos planos y arrays, pero no una función, la instancia de una clase ni un cliente de base de datos. Puedes pasar contenido de servidor a una isla como hijos por un slot —se renderiza antes, en el servidor—, pero cualquier dato que la isla necesite como prop tiene que sobrevivir a esa serialización. La consecuencia de diseño es clara: haz el trabajo rico en el lado del servidor y entrega a la isla solo el dato mínimo y plano que de verdad necesita para vivir.
flowchart TD PG[pagina astro] --> LY[layout de servidor] LY --> CARD[card html sin javascript] CARD --> TXT[titulo precio descripcion] CARD --> ISLA[boton comprar isla hidratada] CARD --> SRV[recomendaciones server island] style ISLA fill:#f38ba8,color:#11111b style SRV fill:#89b4fa,color:#11111b style TXT fill:#a6e3a1,color:#11111b
En verde, lo que llega como HTML puro y no cuesta nada en el cliente; en rojo, la única isla que de verdad se hidrata; en azul, el trozo dinámico que se resuelve en el servidor. Ese es el reparto sano: la mayoría del árbol es estático, la interactividad se concentra en hojas pequeñas, y lo dinámico-no-interactivo se difiere al servidor.
Límites de responsabilidad
La última pieza de la composición es decidir quién hace qué. Un patrón que escala separa los componentes que presentan de los que deciden. Un componente de presentación recibe datos ya listos y solo los pinta: no busca en la base de datos, no conoce rutas, no toma decisiones de negocio; por eso es trivial de reutilizar y de probar. Un componente contenedor —típicamente una página o el nodo raíz de una feature— es quien busca los datos, aplica la lógica y se los pasa a los presentacionales por props y slots.
---
// src/pages/producto/[id].astro — contenedor: busca y decide
import { getProducto } from '@/features/catalogo/lib/datos';
import TarjetaProducto from '@/features/catalogo/TarjetaProducto.astro';
const producto = await getProducto(Astro.params.id);
if (!producto) return Astro.redirect('/404');
---
<TarjetaProducto producto={producto} />
La página busca el producto, decide qué hacer si no existe y entrega el dato ya resuelto; la TarjetaProducto solo recibe y compone, sin sospechar de dónde salió su producto ni qué habría pasado si faltara. Ninguno invade el trabajo del otro, y por eso puedes probar la tarjeta pasándole un producto de mentira, sin base de datos, y cambiar el origen de los datos sin tocar una línea de la presentación.
Ese límite es el mismo del nivel entero, ahora dentro del árbol de UI: lo que cambia por razones de negocio se concentra arriba, en pocos nodos que saben; lo que cambia por razones visuales se reparte abajo, en muchas piezas que ignoran de dónde vienen sus datos. Cuando esa frontera es nítida, rediseñar la interfaz no toca la lógica y cambiar la lógica no toca el diseño, porque viven en componentes distintos con responsabilidades que no se solapan.
Primitivas tontas
Piezas de ui sin dominio que dejan huecos. Cuanto menos saben, en mas sitios sirven.
Slots sobre props
Cuando lo que varia es estructura y no un dato, un slot lo resuelve; las props quedan para lo serializable.
Islas en las hojas
Hidrata lo minimo y lo mas abajo posible; el resto viaja como html de servidor sin coste.
Presentar vs decidir
Contenedores que buscan datos y aplican logica; presentacionales que solo pintan lo que reciben.
Bajo todos estos patrones late un mismo principio, y conviene enunciarlo desnudo: la composición vence a la configuración porque multiplica en vez de sumar. Un componente que intenta cubrir cada caso mediante props crece de forma aditiva —cada necesidad nueva es una prop más, una rama más, una condición más dentro de la misma pieza—, y esa suma tiende a un monolito que lo sabe todo y por eso no sirve para nada nuevo sin tocarlo. Un sistema de primitivas componibles crece de forma multiplicativa: con diez piezas pequeñas y bien delimitadas se expresan cientos de combinaciones que nadie previó al diseñarlas, porque el poder no está en cada pieza sino en las formas de unirlas. Es la misma razón por la que un alfabeto de veintisiete letras vence a un diccionario de pictogramas: no describe más cosas teniendo más símbolos, sino teniendo símbolos que se combinan. Los slots son el mecanismo exacto de esa multiplicación —dejan un hueco donde encaja lo que aún no existe—, y la frontera de la isla es la misma idea aplicada al coste: en vez de una app que decide globalmente si es interactiva, una malla de piezas donde cada una declara localmente si necesita vida propia, de modo que el JavaScript se acumula solo donde de verdad hace falta. Y los límites de responsabilidad son la condición que hace posible la multiplicación: solo se combinan bien las piezas que hacen una sola cosa, porque una pieza que hace tres arrastra sus tres razones de cambio a cada combinación en la que entra y las contamina todas. La lección que trasciende Astro es que la escalabilidad de una interfaz no se mide por lo potentes que son sus componentes sino por lo bien que se combinan los pequeños; que la meta no es la pieza que lo hace todo sino el juego de piezas del que todo se puede hacer. Cuando dejes de preguntarte “¿qué props necesita este componente para cubrir todos los casos?” y empieces a preguntarte “¿en qué piezas mínimas descompongo esto para que se recombinen solas?”, habrás dejado de configurar interfaces y empezado a componerlas.
- Toma un componente cargado de props y detecta cuáles son en realidad contenido; conviértelas en slots con nombre y añade un fallback a los que lo pidan.
- Extrae una primitiva sin dominio a tu paquete de UI y compón sobre ella un componente de dominio que llene sus slots con piezas de una feature.
- Localiza la interactividad de una página y empuja la isla hasta la hoja: hidrata solo el elemento que lo necesita con
client:visibley comprueba cuánto HTML deja de enviarse hidratado. - Separa un componente que hoy busca datos y los pinta en dos: un contenedor que decide y un presentacional que solo recibe y muestra.