Módulo 5: Components With Variants
De utilidades sueltas a un componente
Descripción
La presentación te dijo la tesis: un componente empaqueta utilidades bajo un nombre. Esta lección construye el primer paso de esa idea, el que hace falta antes de tocar cualquier variante: por qué poner la cadena de clases en un componente vale la pena. La respuesta no es "para reutilizar" a secas —esa palabra esconde el mecanismo—. Es concreta: cuando la misma cadena de clases se copia en cada lugar donde aparece el botón, tienes muchas fuentes de verdad para una pieza, y esas fuentes se desincronizan —lo que en la práctica se llama drift: copias que empiezan idénticas y terminan distintas—. Un componente reduce esas muchas fuentes a una sola, y con eso el drift se vuelve imposible por construcción.
Antes de las variantes, entonces, está esto: un componente es una fuente única de verdad para un fragmento de UI. El Button de Mercado —inline-flex items-center rounded-md bg-primary text-white px-4 py-2— aparece en el product-card, en la página de producto y en el mini-carrito. Sin componente, esa cadena vive tres veces; con componente, vive una. Vamos a hacer tangible la diferencia midiendo el drift: cuántas versiones distintas de la misma pieza existen en cada enfoque, y cuántos sitios hay que editar para un solo cambio.
Conexión con el módulo. La presentación dio el mapa; esta pone el cimiento. Es la lección que justifica empaquetar antes de que la lección 3 justifique variar: primero una fuente única (el componente), luego ejes de configuración (las variantes). Todo lo que sigue —base, variants, defaultVariants, compound, cva— vive dentro de esa fuente única; sin ella no hay dónde poner la config. El argumento del drift también conecta con el módulo 3: allá viste que el CSS de utilidades no crece al reusar; aquí veremos que aun así el marcado que copia la cadena sí acumula fuentes que se desincronizan —y el componente resuelve eso—.
Una analogía: la receta pegada en la pared vs cien cocineros de memoria
Imagina una cadena de restaurantes que sirve el mismo sándwich estrella en cien sucursales. Hay dos formas de garantizar que el sándwich sea el mismo en todas.
La primera es que cada cocinero se lo aprenda de memoria. El día de la apertura, los cien cocineros memorizaron la misma receta, así que los cien sándwiches salen idénticos. Pero pasa un año. Un cocinero de la sucursal 47 decide que "queda mejor con un poco más de mostaza" y ajusta su versión; otro, en la 12, cambia el pan porque el proveedor se le acabó. Nadie coordinó nada, y sin embargo el sándwich de la 47 ya no es el de la 3. La receta vivía en cien cabezas —cien fuentes— y las cabezas se fueron separando. Peor: el día que la marca quiera cambiar la salsa oficialmente, tiene que avisar a cien cocineros y confiar en que los cien lo apliquen igual; basta que tres no se enteren para tener tres sándwiches distintos.
La segunda es una sola receta pegada en la pared de cada cocina, y todos la siguen. No hay cien versiones en cien cabezas: hay una receta, y las cocinas la consultan. Si la 47 quiere más mostaza, no puede "ajustar su versión" —no tiene una versión propia, sigue la de la pared—. Y cuando la marca cambia la salsa, cambia la receta, en un lugar, y las cien cocinas sirven la nueva al día siguiente, sin margen para que una se quede atrás. Una fuente, imposible que se desincronicen.
Copiar la cadena de clases en cada uso del botón es la receta en cien cabezas: empieza idéntica y se desincroniza con el tiempo, y un cambio hay que aplicarlo en cada copia. El componente es la receta en la pared: una definición del Button, que todo el storefront consulta. Ese "una sola fuente" es lo que vamos a medir.
Ejemplo trabajado: cuántas versiones del botón existen de verdad
El síntoma del copy-paste no se ve el primer día —las copias nacen idénticas—; se ve con el tiempo, cuando divergen. Vamos a modelar ese momento. Tenemos el botón "Add to cart" en tres lugares del storefront, y —como pasa siempre— alguien tocó una de las copias (en el MiniCart cambió rounded-md por rounded-lg, quizá sin querer). El modelo cuenta cuántas versiones distintas de las clases existen: si es más de una, la misma pieza se ve distinta según dónde esté.
// L2 — por que empaquetar utilidades en un componente: una sola fuente de verdad.
// La misma cadena de clases del boton, repetida en cada lugar donde aparece.
const usages = {
ProductCard: 'inline-flex items-center rounded-md bg-primary text-white px-4 py-2',
ProductPage: 'inline-flex items-center rounded-md bg-primary text-white px-4 py-2',
MiniCart: 'inline-flex items-center rounded-lg bg-primary text-white px-4 py-2', // alguien toco esta
};
// deteccion de "drift": si las copias no son identicas, el boton se ve distinto segun donde este.
const distinct = new Set(Object.values(usages));
console.log('=== boton "Add to cart" copiado en el marcado (sin componente) ===\n');
console.log('lugares que lo usan: ' + Object.keys(usages).length);
console.log('versiones DISTINTAS de clases: ' + distinct.size + ' (deberia ser 1)');
for (const [place, cls] of Object.entries(usages)) console.log(' ' + place.padEnd(12) + cls);
// con un componente Button, la cadena vive en un solo lugar: imposible que difieran.
console.log('\n=== con un componente Button (una sola fuente) ===');
console.log('lugares que lo usan: ' + Object.keys(usages).length);
console.log('versiones DISTINTAS de clases: 1 (la definicion del Button)');
console.log('sitios a editar para un cambio: 1');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== boton "Add to cart" copiado en el marcado (sin componente) ===
lugares que lo usan: 3
versiones DISTINTAS de clases: 2 (deberia ser 1)
ProductCard inline-flex items-center rounded-md bg-primary text-white px-4 py-2
ProductPage inline-flex items-center rounded-md bg-primary text-white px-4 py-2
MiniCart inline-flex items-center rounded-lg bg-primary text-white px-4 py-2
=== con un componente Button (una sola fuente) ===
lugares que lo usan: 3
versiones DISTINTAS de clases: 1 (la definicion del Button)
sitios a editar para un cambio: 1
Lee la salida en dos tiempos. En el primero —sin componente— el botón se usa en 3 lugares, pero hay 2 versiones distintas de sus clases. El product-card y la página de producto siguen con rounded-md; el mini-carrito quedó con rounded-lg. Nadie decidió que el botón del carrito tuviera las esquinas más redondeadas: se desincronizó. Y lo que hace peligroso a este drift es que es silencioso —las tres copias "funcionan", ninguna da error, solo se ven un poco distintas, y probablemente nadie lo note hasta que un usuario o un diseñador lo señale—. Con la receta en tres cabezas, una cabeza cambió.
En el segundo tiempo —con un componente Button— el botón se sigue usando en 3 lugares, pero solo hay 1 versión posible de sus clases: la que vive en la definición del Button. Los tres lugares no copian la cadena; la consultan. No hay forma de que el mini-carrito tenga rounded-lg y los demás rounded-md, porque no hay tres cadenas —hay una—. Y cuando Mercado quiera cambiar el radio de todos sus botones, edita 1 sitio (la definición) y los tres usos lo heredan, en vez de buscar y editar cada copia con el riesgo de olvidar una. Una fuente, cero drift, un solo sitio de cambio.
Así se ve la fuente única en el marcado real (esto se muestra —es lo que escribes—). En vez de repetir la cadena en cada lugar, la defines una vez en el Button y renderizas <Button> donde la necesites:
// Button.jsx — la fuente unica de verdad del boton de Mercado.
function Button({ children }) {
return (
<button className="inline-flex items-center rounded-md bg-primary text-white px-4 py-2">
{children}
</button>
);
}
// y en el storefront, los tres lugares lo consumen (no copian la cadena):
// ProductCard: <Button>Add to cart</Button>
// ProductPage: <Button>Add to cart</Button>
// MiniCart: <Button>Add to cart</Button>
Fíjate en que la cadena de clases aparece una sola vez, dentro de Button. Los tres usos escriben <Button>Add to cart</Button> —el mismo nombre, ninguna clase—. Es la receta en la pared: las cocinas la siguen, no la reescriben.
Una pregunta hacia la lección 3: este Button resuelve el drift, pero es rígido —siempre se ve igual—. ¿Qué haces cuando Mercado necesita el botón "ghost" del filtro, o el botón "chico" de la barra lateral? No puedes copiar Button y cambiarle dos clases (volverías al problema que acabas de resolver). Tampoco quieres un Button distinto por apariencia. (Guarda la pregunta: la respuesta son las variantes, y empiezan en la lección 3.)
Profundización: por qué el drift es peor que la duplicación de CSS del módulo 3
Podrías objetar, con razón, algo del módulo 3: allá aprendiste que repetir p-4 rounded-md bg-primary en varios componentes no repite CSS —la regla .p-4 sigue siendo una sola, y el CSS generado no crece—. Entonces, ¿cuál es el problema de repetir la cadena de clases del botón en tres lugares, si el CSS no crece?
El problema no está en el CSS; está en el marcado como fuente de verdad. En el módulo 3 medimos el tamaño del CSS generado, y ese no crece al reusar utilidades. Pero aquí medimos otra cosa: cuántos lugares independientes definen "cómo es el botón de Mercado". Aunque el CSS no crezca, cada copia de la cadena en el marcado es una decisión duplicada sobre la apariencia del botón —tres afirmaciones separadas de "el botón lleva estas clases"—. Y las decisiones duplicadas se desincronizan: no cuestan bytes de CSS, cuestan coherencia. El módulo 3 te dijo que las utilidades no hacen crecer tu hoja de estilos; esta lección te dice que aun así necesitas un lugar que sea dueño de la definición del botón, o las copias driftan. Son dos problemas distintos: el módulo 3 cuida el peso del CSS; el componente cuida la unicidad de la definición.
Dicho de otro modo: las utilidades resolvieron "no repetir reglas de estilo"; el componente resuelve "no repetir la definición del componente". Puedes tener lo primero y aun así sufrir lo segundo —que es justo lo que muestra el ejemplo: las tres copias usan las mismas utilidades (mismo CSS), pero son tres definiciones separadas del botón, y una se salió de línea—.
Errores comunes
Copiar la cadena de clases "porque es más rápido que crear el componente". Qué pasa: se pega inline-flex items-center rounded-md bg-primary text-white px-4 py-2 en cada botón del storefront, en vez de definir un Button. Por qué pasa: el primer día, copiar es literalmente más rápido —el componente parece ceremonia innecesaria—. Cómo detectarlo: la misma cadena de clases aparece textualmente en varios archivos; un cambio de diseño te obliga a un "buscar y reemplazar" por todo el repo. Cómo corregirlo: en cuanto una pieza aparece dos veces con las mismas clases, es candidata a componente. El costo de crear el Button se paga una vez; el costo del drift se paga para siempre, y en silencio. La rapidez del día 1 es la deuda del mes 6.
Confundir "el CSS no crece" (módulo 3) con "no hace falta un componente". Qué pasa: se argumenta que como las utilidades no duplican CSS, copiar la cadena en el marcado no tiene costo. Por qué pasa: se mezclan dos mediciones distintas —el peso del CSS y la unicidad de la definición—. Cómo detectarlo: defiendes el copy-paste con "pero el CSS no crece". Cómo corregirlo: es cierto que el CSS no crece, y aun así cada copia es una fuente de verdad separada que puede driftar. El componente no existe para achicar el CSS (eso ya lo hacen las utilidades); existe para que la definición del botón viva en un lugar. Dos problemas, dos herramientas: utilidades para el CSS, componente para la unicidad.
Crear un componente por cada variación en vez de una fuente con ejes. Qué pasa: para no copiar clases, se hace PrimaryButton, SecondaryButton, GhostButton —un componente por apariencia—. Por qué pasa: "un componente por caso" suena a la solución correcta al copy-paste. Cómo detectarlo: tienes tres o cuatro componentes que se llaman XButton y comparten el 80% de sus clases. Cómo corregirlo: eso solo mueve el drift de la cadena de clases a la familia de componentes —ahora el look común vive duplicado en tres archivos—. La solución no es más componentes; es un Button con ejes (variant, size) resueltos por props. Es exactamente el salto de la lección 3; este error es la trampa que esa lección desactiva.
Ejercicios
Ejercicio 1 — Cuenta las versiones. El botón "Buy now" de Mercado aparece en cuatro lugares, con estas clases: A: 'rounded-md bg-primary text-white px-4', B: 'rounded-md bg-primary text-white px-4', C: 'rounded-md bg-primary text-white px-6', D: 'rounded-lg bg-primary text-white px-4'. (a) ¿Cuántas versiones distintas existen? (b) ¿Cuáles driftearon respecto a la mayoría? (c) ¿Cuántas versiones habría con un componente Button?
Ver solución
- (a) Tres versiones distintas.
AyBson idénticas (una versión);Ccambiapx-4porpx-6(otra);Dcambiarounded-mdporrounded-lg(otra). Tres cadenas únicas para una pieza que debería tener una. - (b)
CyDdriftearon. La mayoría (A,B) usarounded-md ... px-4;Cse salió en el padding yDen el radio. Ninguno de los dos fue una decisión de diseño —son copias que se separaron—. - (c) Una. Con un
Button, la cadena vive en la definición y los cuatro lugares la consultan. Es imposible queCtengapx-6yDtengarounded-lg, porque no hay cuatro cadenas —hay una—.
Ejercicio 2 — Un sitio o cuatro. Mercado decide que todos sus botones pasen de rounded-md a rounded-lg. (a) Con la cadena copiada en 12 lugares, ¿cuántos sitios editas, y qué riesgo corres? (b) Con un componente Button, ¿cuántos sitios editas? (c) ¿Qué garantiza el componente que el "buscar y reemplazar" no?
Ver solución
- (a) 12 sitios, con el riesgo de olvidar uno (o de que un "buscar y reemplazar" tropiece con una copia que ya había drifteado y no coincide con el patrón de búsqueda). Basta un olvido para reintroducir el drift.
- (b) 1 sitio. Cambias
rounded-mdporrounded-lgen la definición delButton, y los 12 usos lo heredan. - (c) Garantiza que el cambio se aplica a todos, sin excepción posible. El "buscar y reemplazar" depende de que las 12 copias sean textualmente iguales y de que no olvides ninguna; el componente no depende de eso porque no hay 12 copias —hay una definición—. La unicidad no es "más cómoda": es la única que garantiza coherencia.
Ejercicio 3 — Predice el drift. Sin correr nada, di qué imprimiría la primera parte del ejemplo (versiones DISTINTAS de clases) si el MiniCart no hubiera sido tocado —es decir, si sus clases fueran idénticas a las de ProductCard y ProductPage—.
Ver solución
Imprimiría 1. Si las tres copias son textualmente idénticas, el Set que deduplica las cadenas colapsa a un solo elemento: hay tres usos pero una sola versión distinta. La línea diría versiones DISTINTAS de clases: 1 (deberia ser 1) —el caso sano—. La lección: el copy-paste puede estar sincronizado hoy (todas las cabezas recuerdan la misma receta) y aun así ser frágil, porque nada impide que mañana una copia se separe. El componente no es mejor porque hoy estén sincronizadas; es mejor porque hace el drift imposible, no solo ausente por ahora.
Resumen y siguiente paso
En esta lección estableciste el cimiento de la capa de componentes: un componente es una fuente única de verdad para un fragmento de UI, y esa unicidad hace el drift imposible por construcción. Con la receta en la pared frente a cien cocineros de memoria viste el mecanismo: copiar la cadena de clases en cada uso son muchas fuentes que se desincronizan en silencio; un componente reduce esas fuentes a una que todos consultan. Y lo mediste: el botón copiado en tres lugares ya tenía 2 versiones distintas (una copia drifteó a rounded-lg), mientras que con un Button solo hay 1 versión posible y 1 sitio que editar para cambiarla. También separaste este problema del módulo 3: las utilidades cuidan que el CSS no crezca; el componente cuida que la definición del botón sea única —dos problemas, dos herramientas—.
Antes de avanzar deberías poder: explicar qué es el drift y por qué es silencioso; distinguir "el CSS no crece" (módulo 3) de "la definición es única" (componente); y decir por qué duplicar el componente por variación (PrimaryButton, GhostButton) reintroduce el problema.
La lección 3 abre el verdadero tema del módulo: el patrón de variantes. Ahora que el Button es una fuente única, ¿cómo le agregas variación —ghost, chico, secundario— sin copiar el componente ni llenarlo de props booleanas que se contradicen? Vas a ver el enfoque ingenuo (una prop booleana por look) explotar en combinaciones contradictorias —medido en Node— y el enfoque de ejes (variant, size) resolverlo con cero contradicciones. Es el paso de "un botón fijo" a "un botón configurable".
Recursos
- Tailwind CSS, "Styling with utility classes" (sección "Reusing styles") — tailwindcss.com/docs/styling-with-utility-classes. La recomendación oficial de empaquetar utilidades repetidas en un componente en vez de copiarlas —exactamente el paso de esta lección—. En inglés.
- React, "Your First Component" — react.dev/learn/your-first-component. El prerequisito de
react-fundamentals: qué es un componente y por qué encapsula un fragmento de UI reutilizable. En inglés. - React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. Cómo un componente recibe
childreny props —lo que elButtonde esta lección usa para envolver su contenido—. En inglés. - Nathan Curtis, "Naming Tokens in Design Systems" — medium.com/eightshapes-llc/naming-tokens-in-design-systems-9e86c7444676. Sobre la fuente única de verdad en sistemas de diseño y por qué la unicidad evita la deriva. En inglés.