Módulo 5: Components With Variants
Composición sobre props
Descripción
Las variantes resuelven cómo se ve un componente: variant, size, un compound. Pero no toda variación es de apariencia, y ahí las variantes se quedan cortas. Dos ejemplos que vas a encontrar el primer día: quieres que el Button de Mercado sea a veces un enlace (<a href>) en vez de un <button> —mismo estilo, distinto elemento—; y quieres una Card con zonas configurables —un encabezado aquí, un cuerpo allá, un pie opcional—. Si intentas modelar eso con props, vuelves a la explosión de la lección 3: isLink, href, target, hasHeader, headerContent, hasFooter, footerContent… props que se multiplican y se contradicen.
La salida no es más props: es composición. En vez de que el componente reciba todo lo que podría necesitar como props, dejas que el que lo usa componga piezas: le pasa contenido por children (lo natural de React), le da zonas por slots, o —el patrón clave de este módulo— usa asChild para que el componente preste su estilo a otro elemento sin envolverlo. Vas a ver, ejecutado, cómo asChild fusiona las clases del Button en un <a>: el mismo botón de marca, ahora como enlace, sin agregar una sola prop nueva.
Conexión con el módulo. Las lecciones 2–6 cubrieron una mitad de construir componentes de sistema —empaquetar y variar la apariencia con cva—. Esta abre la otra mitad: componer en vez de acumular props. Es la lección que evita que tu Button termine con treinta props tratando de anticipar cada uso. También es el puente al módulo 7: asChild es un patrón que las primitivas (Radix, shadcn) llevan al extremo —el Slot de Radix es la implementación real de lo que aquí modelamos pedagógicamente—. Y toca la frontera con react-fundamentals: la lógica de qué hace el botón al hacer clic no vive en la capa de UI —eso lo retomamos al final—.
Una analogía: el mueble modular vs el mueble de mil perillas
Imagina dos formas de vender un mueble de sala.
La primera es un mueble con mil perillas: un solo bloque enorme que trae, de fábrica, todas las funciones posibles activables con interruptores. ¿Quieres un estante? Perilla 14. ¿Un cajón? Perilla 27. ¿Que el estante sea de vidrio? Perilla 27-b. ¿Una lámpara integrada? Perilla 41. El catálogo del mueble tiene doscientas perillas, la mayoría apagadas para cualquier cliente concreto, y varias que se contradicen ("no puedes activar cajón y puerta corrediza en el mismo hueco"). Configurarlo es una pesadilla, y cada función nueva es otra perilla en un panel ya saturado.
La segunda es un sistema modular: piezas estándar —paneles, estantes, cajones, patas— que el cliente combina como quiera. No hay un mueble que lo haga todo; hay piezas que encajan. ¿Quieres un estante de vidrio arriba y un cajón abajo? Pones esa pieza arriba y esa otra abajo. El sistema no anticipa cada configuración con una perilla: te da piezas componibles y tú armas la que necesitas. Una función nueva es una pieza nueva que encaja con las demás, no otra perilla en el panel.
Las props son las perillas; la composición son las piezas modulares. Un componente que intenta anticipar cada uso con props es el mueble de mil perillas —satura su API y llega a contradicciones—. Un componente componible da piezas (children, slots, asChild) que el que lo usa combina. asChild, en particular, es la pieza que dice "usa mi estilo, pero encájalo en tu elemento" —el estante que puedes montar en cualquier hueco—.
Ejemplo trabajado: asChild — el mismo Button, distinto elemento
El caso más limpio de composición es asChild. Problema: el Button de Mercado se ve perfecto, pero a veces necesitas que sea un enlace (<a href>) —por ejemplo, "Ver producto" navega a otra página, así que semánticamente es un <a>, no un <button>—. La tentación es una prop: <Button as="a" href="...">. asChild lo hace mejor: en vez de que el Button sea un enlace, le presta sus clases al <a> que tú le pasas como hijo. El Button no renderiza un <button>; fusiona su estilo en el hijo y deja que el hijo mande su propio tag.
// L7 — composicion: asChild fusiona las clases del Button en el hijo, sin envolver.
// (reusa la mini-cva; aqui el foco es COMPONER, no agregar mas props)
function variants(config, props = {}) {
const classes = config.base ? [config.base] : [];
for (const axis of Object.keys(config.variants ?? {})) {
const value = props[axis] !== undefined ? props[axis] : config.defaultVariants?.[axis];
const cls = config.variants[axis]?.[value];
if (cls) classes.push(cls);
}
return classes.join(' ');
}
const buttonConfig = {
base: 'inline-flex items-center rounded-md font-medium',
variants: {
variant: { primary: 'bg-primary text-white', ghost: 'bg-transparent text-primary' },
size: { md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
},
defaultVariants: { variant: 'primary', size: 'md' },
};
// asChild: en vez de renderizar <button>, fusiona las clases del Button en el elemento hijo.
function renderButton({ asChild = false, child, ...props }) {
const buttonClasses = variants(buttonConfig, props);
if (!asChild) return { tag: 'button', className: buttonClasses };
// fusiona: las clases del Button + las que el hijo ya traia (el hijo manda su tag).
const merged = (buttonClasses + ' ' + (child.className ?? '')).trim();
return { tag: child.tag, className: merged };
}
console.log('=== asChild: el mismo Button, distinto elemento ===\n');
const asButton = renderButton({ variant: 'primary', size: 'lg' });
console.log('normal -> <' + asButton.tag + ' class="' + asButton.className + '">');
const asLink = renderButton({ variant: 'primary', size: 'lg', asChild: true, child: { tag: 'a', className: 'no-underline' } });
console.log('asChild -> <' + asLink.tag + ' class="' + asLink.className + '">');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== asChild: el mismo Button, distinto elemento ===
normal -> <button class="inline-flex items-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3">
asChild -> <a class="inline-flex items-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3 no-underline">
Compara las dos líneas. La normal renderiza un <button> con las clases que las variantes resolvieron (base + primary + lg): el botón de siempre. La asChild produce un <a> —fíjate: el tag cambió— con las mismas clases del Button más la que el hijo ya traía (no-underline). No hay un <button> envolviendo al <a>; el <a> es el elemento, vestido con el estilo del Button. Semánticamente correcto (un enlace es un <a>), visualmente idéntico (lleva las clases del botón primary grande), y sin una sola prop nueva —no hay as, ni href en el Button; el href vive en el <a> que compusiste—.
Ese es el poder de asChild: separa el estilo del elemento. El Button aporta el "cómo se ve"; el hijo aporta el "qué elemento es" y sus propios atributos. Lo mismo sirve para envolver el botón en el <Link> de tu router, o en cualquier componente que necesite verse como botón sin ser un <button>. Sin asChild, cada uno de esos casos sería una prop más (isLink, isRouterLink, isExternalLink…) —la explosión de la lección 3, otra vez—. Con asChild, es una pieza componible que cubre todos.
Así se ve asChild en el Button real, con el Slot de Radix (esto se muestra; el Slot es la implementación de producción de lo que el modelo emula):
// Button.jsx — asChild real con el Slot de Radix.
import { Slot } from '@radix-ui/react-slot';
function Button({ asChild = false, variant, size, className, ...props }) {
// Si asChild, renderiza un Slot que "se convierte" en su hijo, prestandole las clases.
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
// uso normal: <Button variant="primary" size="lg">Add to cart</Button> -> <button>
// uso asChild: <Button asChild variant="primary" size="lg">
// <a href="/product/42">Ver producto</a> -> <a> con estilo de Button
Profundización: composición vs variante — cuándo cada una, y la frontera con la lógica
¿Cómo decides si algo es una variante o un caso de composición? La regla es limpia. Una variante es cuando el componente se ve distinto pero es la misma cosa: un botón primary y uno ghost son ambos botones, cambia el aspecto → eje variant. La composición es cuando lo que varía es la estructura o el elemento: qué tag es (<button> vs <a>), qué piezas contiene (encabezado, cuerpo, pie), qué contenido lleva → children, slots, asChild. Dicho corto: si es "cómo se ve", es una variante; si es "de qué está hecho" o "qué elemento es", es composición. El size del botón es variante; que el botón sea un enlace es composición.
La composición tiene una segunda virtud, más allá de evitar la explosión de props: mantiene el componente tonto sobre el contenido. Una Card con slots no sabe qué va en su encabezado —solo sabe dónde ponerlo—; el que la usa compone el contenido. Eso la hace reutilizable en contextos que su autor nunca anticipó, porque no cableó supuestos sobre el contenido dentro. Un componente que recibe headerTitle, headerSubtitle, headerIcon, headerBadge… ya decidió por ti qué puede ir en el encabezado; uno con un slot de encabezado te deja poner lo que sea. Menos props, más reúso.
Y aquí toca la frontera con react-fundamentals, que este módulo respeta a propósito: la composición y las variantes son de la capa de UI —cómo se ve y de qué se compone—; la lógica de negocio no vive en el componente de UI. El Button de Mercado se ve de cierta forma (variantes) y puede prestar su estilo a un enlace (composición), pero qué pasa al hacer clic en "Add to cart" —agregar al carrito, llamar al servidor, actualizar el total— no es asunto suyo. Eso llega por un onClick que el que usa el botón le pasa (eso es react-fundamentals), o vive en la capa de estado/datos (frontend-state-and-data). Un Button que dentro sabe cómo agregar cosas al carrito es un Button que solo sirve para ese carrito —dejó de ser una pieza de sistema y se volvió una pieza de negocio—. Mantén la UI ignorante del negocio: recibe props de apariencia, compone contenido, dispara callbacks; no decide reglas de negocio.
Errores comunes
Agregar una prop as/isLink en vez de usar asChild. Qué pasa: para que el botón sea un enlace se agrega <Button as="a" href="...">, y luego as="span", as="div"… con sus atributos. Por qué pasa: una prop as parece la solución directa. Cómo detectarlo: tu Button acepta as, href, target, rel… atributos que no son suyos sino del elemento que quiere ser. Cómo corregirlo: asChild invierte el control —el hijo aporta el tag y sus atributos, el Button solo presta el estilo—. Así el Button no necesita conocer href ni target; esos viven en el <a> que compones. Una pieza componible en vez de una prop por cada elemento posible.
Explotar la API con props de contenido (headerTitle, footerContent…). Qué pasa: una Card recibe headerTitle, headerSubtitle, bodyText, footerButtonLabel… una prop por cada trozo de contenido. Por qué pasa: al principio hay pocos trozos y las props parecen suficientes. Cómo detectarlo: la Card tiene diez props de contenido y aun así no cubre un caso nuevo (un encabezado con dos botones). Cómo corregirlo: da slots —zonas que reciben children— y deja que el que usa la Card componga el contenido. <Card><CardHeader>...</CardHeader><CardBody>...</CardBody></Card>. La Card decide el layout de las zonas; el contenido lo compone quien la usa. Menos props, infinitamente más flexible.
Meter lógica de negocio dentro del componente de UI. Qué pasa: el Button de "Add to cart" incluye, dentro, la llamada que agrega el producto al carrito. Por qué pasa: el botón "es" el de agregar al carrito, así que parece natural que sepa hacerlo. Cómo detectarlo: tu Button importa el store del carrito o hace fetch; no lo puedes reusar para "Add to wishlist" sin editarlo. Cómo corregirlo: el Button recibe un onClick y lo dispara; qué hace ese onClick lo decide quien lo usa. Así el mismo Button sirve para el carrito, la wishlist o cualquier acción. La UI se ve y compone; el negocio vive fuera (en react-fundamentals/frontend-state-and-data). Un componente de UI que sabe de negocio deja de ser sistema.
Ejercicios
Ejercicio 1 — ¿Variante o composición? Para cada necesidad, di si se resuelve con una variante (cómo se ve) o con composición (children/slots/asChild):
- (a) El botón puede ser primary, secondary o ghost.
- (b) El botón a veces es un
<a>que navega a otra página. - (c) El botón puede ser chico, mediano o grande.
- (d) La
Carda veces tiene pie y a veces no.
Ver solución
- (a) Variante (
variant). Cambia cómo se ve, sigue siendo un botón. Eje de apariencia. - (b) Composición (
asChild). Cambia qué elemento es (<a>vs<button>), no su aspecto. El hijo aporta el tag y elhref. - (c) Variante (
size). Cómo se ve; eje de apariencia. - (d) Composición (slot/
childrenopcional). Cambia de qué está hecha la card (con o sin la zona de pie), no su aspecto base. El que la usa compone el pie —o no—.
La regla: "cómo se ve" → variante; "de qué está hecho / qué elemento es" → composición.
Ejercicio 2 — Predice la fusión. Con el modelo del ejemplo, sin correr nada, di qué imprimiría renderButton({ variant: 'ghost', size: 'md', asChild: true, child: { tag: 'a', className: 'font-bold' } }).
Ver solución
<a class="inline-flex items-center rounded-md font-medium bg-transparent text-primary text-base px-4 py-2 font-bold">
Las variantes resuelven base + ghost (bg-transparent text-primary) + md (text-base px-4 py-2), y como asChild es true, esas clases se fusionan con la que el hijo traía (font-bold), y el tag es el del hijo (a). El estilo del Button ghost mediano, montado en un enlace, más la clase propia del enlace. (Nota: font-medium de la base y font-bold del hijo chocan en font-weight; en producción cn/tailwind-merge —lección 6— resolvería que gane font-bold. El modelo pedagógico solo concatena, para enfocarse en la fusión de tag+clases.)
Ejercicio 3 — Rescata el componente. Un Button acumuló estas props: variant, size, isLink, href, target, onClickAddToCart, productId. Clasifica cada una en: variante (se queda), composición (se reemplaza por asChild/children), o lógica de negocio (sale del componente).
Ver solución
variant,size→ variantes. Se quedan; son la apariencia, el trabajo legítimo del componente.isLink,href,target→ composición. Se reemplazan porasChild: el que usa el botón le pasa un<a href target>como hijo, y esos tres atributos desaparecen de la API delButton.onClickAddToCart,productId→ lógica de negocio. Salen del componente. ElButtonrecibe unonClickgenérico; qué hace (agregar el productoproductIdal carrito) lo decide quien lo usa, pasándole el callback ya armado. ElButtonno conoce el carrito ni elproductId.
El Button rescatado: variant, size (variantes), asChild + children (composición), onClick + ...props (callbacks genéricos). De siete props acopladas a un uso, a un componente de sistema que sirve para cualquier acción y cualquier elemento.
Resumen y siguiente paso
En esta lección abriste la otra mitad de construir componentes de sistema: la composición —children, slots, asChild— para lo que las variantes no cubren, en vez de acumular props que explotan. Con el mueble modular frente al de mil perillas viste la distinción: un componente que anticipa cada uso con props satura su API y llega a contradicciones; uno componible da piezas que el que lo usa combina. Y lo ejecutaste: asChild fusionó las clases del Button en un <a> —mismo estilo, distinto tag, cero props nuevas—, separando el "cómo se ve" (del Button) del "qué elemento es" (del hijo). Fijaste la regla —"cómo se ve" es variante, "de qué está hecho / qué elemento es" es composición— y la frontera: la UI se ve y compone; la lógica de negocio vive fuera del componente (react-fundamentals, frontend-state-and-data).
Antes de avanzar deberías poder: decidir si una necesidad es variante o composición; explicar qué separa asChild (estilo vs elemento); y sacar la lógica de negocio de un componente de UI dejando solo callbacks genéricos.
La lección 8 es el proyecto: construir el Button de Mercado con variantes y aplicarlo al product-card. Vas a juntar todo el módulo —base, variants (variant × size), defaultVariants, un compound (primary + lg → shadow-lg)— en el Button real, y comprobar con el modelo en Node la lista de clases resuelta para varias combinaciones de props. Luego lo montas en el CTA del product-card, cerrando la capa de componentes de Mercado sobre las capas de tokens, utilidades y escalas de los módulos anteriores.
Recursos
- Radix UI, "Composition" (
asChildySlot) — radix-ui.com/primitives/docs/guides/composition. La implementación real deasChildconSlot; lo que el modelo de esta lección emula. Se profundiza en el módulo 7. En inglés. - React, "Passing JSX as children" — react.dev/learn/passing-props-to-a-component#passing-jsx-as-children. Cómo
childrenda slots naturales; el fundamento de la composición, desdereact-fundamentals. En inglés. - shadcn/ui, "Button" (prop
asChild) — ui.shadcn.com/docs/components/button. UnButtonde producción conasChild; el mismo patrón sobre cva + Slot. En inglés. - Kent C. Dodds, "Compound Components" — kentcdodds.com/blog/compound-components-with-react-hooks. Composición con slots (
Card+CardHeader…) para evitar la explosión de props de contenido. En inglés.