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.
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 dominiointerface 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 primitivaimport 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 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 islandimport 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.
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 decideimport { 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.