Módulo 5: Components With Variants
El patrón de variantes
Descripción
La lección 2 te dio un Button que es una fuente única de verdad —pero rígido: siempre se ve igual—. Esta lección resuelve cómo darle variación controlada sin romper esa unicidad. Y empieza por descartar la solución que a todo el mundo se le ocurre primero, porque es una trampa: las props booleanas. La idea ingenua es agregar una bandera por apariencia —isPrimary, isSecondary, isGhost, isSmall, isLarge— y dentro del componente decidir clases según cuáles estén activas. Suena razonable la primera tarde. La segunda tarde ya tienes un botón que acepta isPrimary y isGhost a la vez —dos cosas que se contradicen— y nada lo impide.
La solución correcta es el patrón de variantes: en vez de muchas banderas booleanas independientes, defines ejes. Un eje es una prop que toma una opción de un conjunto excluyente: variant es "primary" o "secondary" o "ghost" —nunca dos—; size es "sm" o "md" o "lg". Por construcción, un eje no puede contradecirse consigo mismo. Vas a ver, medido en Node, la diferencia brutal: cuántas combinaciones contradictorias generan las booleanas frente a las cero de los ejes.
Conexión con el módulo. La lección 2 empaquetó las utilidades en un componente; esta le da la forma de configuración por ejes que el resto del módulo desarrolla. Es la lección-bisagra: después de ella, variants deja de ser "unas clases sueltas" y se vuelve "un objeto con un eje por prop", que es justo la estructura que la lección 4 formaliza (base + variants + defaultVariants) y la 6 conecta con cva. El argumento —ejes excluyentes en vez de banderas que explotan— es el que sostiene todo el patrón; sin él, cva sería solo azúcar sobre un if.
Una analogía: los perilla de la radio vs cien interruptores sueltos
Imagina el tablero de un coche viejo y el de uno moderno, los dos para elegir la temperatura del clima.
El coche moderno tiene una perilla de temperatura: la giras y apunta a un número entre frío y caliente. No hay forma de que apunte a "18°" y "26°" al mismo tiempo —la perilla es una, y está en una posición—. Elegir es imposible de arruinar: cualquier posición es válida, y solo hay una activa.
El coche viejo, en cambio, tiene cinco interruptores sueltos: "frío", "templado", "caliente", "muy caliente", "helado", cada uno con su palanquita de encendido/apagado. En teoría enciendes el que quieres. Pero nada impide encender "frío" y "caliente" a la vez —dos palancas arriba que se contradicen—, y entonces, ¿qué hace el clima? Nadie sabe. Con cinco palancas independientes hay 32 configuraciones posibles del tablero, y la mayoría no tiene sentido: "frío + caliente", "helado + muy caliente", "los cinco encendidos". El diseño permite estados imposibles, y tarde o temprano alguien cae en uno.
Las props booleanas del botón son los cinco interruptores sueltos: isPrimary, isGhost, isSmall… cada una una palanca, y nada impide subir dos que se contradicen. El eje variant es la perilla: una prop, una posición entre opciones excluyentes, imposible de contradecir. El patrón de variantes es cambiar los interruptores sueltos por perillas —un eje por dimensión de la apariencia—. Esa reducción de "estados imposibles" es lo que vamos a medir.
Ejemplo trabajado: la explosión de las booleanas frente a los ejes
Vamos a contar, en Node, cuántas combinaciones contradictorias permite cada enfoque. Con cinco props booleanas (isPrimary, isSecondary, isGhost, isSmall, isLarge), hay 2^5 = 32 combinaciones posibles de encendido/apagado. Una combinación es contradictoria si enciende dos "looks" a la vez (dos de primary/secondary/ghost) o dos "tamaños" a la vez (small y large) —cosas que no pueden ser ciertas juntas—. Enumeramos las 32 y contamos las contradictorias. Luego hacemos lo mismo con el enfoque de ejes: variant (3 opciones) × size (3 opciones).
// L3 — el patron de variantes: ejes con opciones, no props booleanas que explotan.
// Enfoque ingenuo: una prop booleana por look. N booleanas -> 2^N combinaciones.
const booleanProps = ['isPrimary', 'isSecondary', 'isGhost', 'isSmall', 'isLarge'];
const combos = 1 << booleanProps.length; // 2^5
// muchas de esas combinaciones son CONTRADICTORIAS: isPrimary && isGhost, isSmall && isLarge.
function isContradictory(state) {
const looks = ['isPrimary', 'isSecondary', 'isGhost'].filter((k) => state[k]).length;
const sizes = ['isSmall', 'isLarge'].filter((k) => state[k]).length;
return looks > 1 || sizes > 1; // dos looks o dos tamanos a la vez: imposible
}
let contradictory = 0;
for (let mask = 0; mask < combos; mask++) {
const state = {};
booleanProps.forEach((k, i) => { state[k] = Boolean(mask & (1 << i)); });
if (isContradictory(state)) contradictory++;
}
console.log('=== props booleanas: la explosion ===\n');
console.log('props booleanas: ' + booleanProps.length);
console.log('combinaciones posibles (2^N): ' + combos);
console.log('combinaciones CONTRADICTORIAS: ' + contradictory + ' (p.ej. isPrimary && isGhost)');
// Enfoque por ejes: cada eje es UNA prop con opciones excluyentes.
const axes = { variant: ['primary', 'secondary', 'ghost'], size: ['sm', 'md', 'lg'] };
const valid = axes.variant.length * axes.size.length;
console.log('\n=== por ejes: variant x size ===');
console.log('combinaciones validas: ' + valid + ' (3 variant x 3 size)');
console.log('combinaciones contradictorias: 0 (un eje solo toma UN valor)');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== props booleanas: la explosion ===
props booleanas: 5
combinaciones posibles (2^N): 32
combinaciones CONTRADICTORIAS: 20 (p.ej. isPrimary && isGhost)
=== por ejes: variant x size ===
combinaciones validas: 9 (3 variant x 3 size)
combinaciones contradictorias: 0 (un eje solo toma UN valor)
Lee los dos bloques como los dos coches. El primero es el tablero de interruptores sueltos. Cinco booleanas dan 32 combinaciones posibles, y de esas, 20 son contradictorias —el 62%—: isPrimary && isGhost (dos looks), isSmall && isLarge (dos tamaños), isPrimary && isSecondary && isLarge… Más de la mitad de lo que tu componente acepta no tiene sentido, y tu código tiene que lidiar con todos esos estados imposibles: ¿qué clases pone si le pasan isPrimary y isGhost? Cualquier respuesta que elijas es un parche para un estado que nunca debió existir. El diseño de la API permite el sinsentido, así que el sinsentido va a llegar.
El segundo bloque es la perilla. Dos ejes —variant con 3 opciones, size con 3— dan 9 combinaciones, y 0 contradictorias. No hay ninguna que arreglar, porque variant es una prop que toma un valor: no existe "primary y ghost a la vez" del mismo modo que la perilla no puede apuntar a dos temperaturas. Pasaste de 32 estados (20 basura) a 9 estados (todos válidos). El patrón no solo es más limpio: hace los estados imposibles, literalmente, inexpresables. No confías en que nadie encienda dos interruptores; quitas los interruptores y pones una perilla.
Así se ve el eje en la API del componente (esto se muestra). En vez de banderas, el Button recibe variant y size como props de un conjunto conocido:
// Button.jsx — ejes en vez de booleanas. (la resolucion de clases se formaliza en L4)
function Button({ variant = 'primary', size = 'md', children }) {
// variant: 'primary' | 'secondary' | 'ghost' (una perilla)
// size: 'sm' | 'md' | 'lg' (otra perilla)
const className = resolveClasses(variant, size); // el motor de la L4
return <button className={className}>{children}</button>;
}
// uso: <Button variant="ghost" size="lg">Filtrar</Button>
// NO existe <Button isPrimary isGhost> — el eje no deja expresarlo.
Fíjate en <Button variant="ghost" size="lg">: dos perillas, dos posiciones, sin ambigüedad. Y date cuenta de lo que no puedes escribir: no hay manera de pedir "primary y ghost a la vez", porque variant es una sola prop. La API te protege del error en vez de confiar en que no lo cometas.
Una pregunta hacia la lección 4: ya sabes que variant y size son ejes con opciones, y que cada opción corresponde a un conjunto de clases (ghost → bg-transparent text-primary). Pero, ¿dónde vive ese mapa de "opción → clases"? ¿Y qué pasa cuando alguien renderiza <Button> sin pasar ni variant ni size —qué botón sale?— (Guarda la pregunta: la estructura base + variants + defaultVariants de la lección 4 la responde.)
Profundización: "hacer imposibles los estados imposibles"
Hay un principio de diseño de APIs que esta lección ilustra al pie de la letra: make impossible states impossible —hacer que los estados imposibles no se puedan ni expresar—. La idea es que la mejor forma de evitar un bug no es manejar el estado inválido, sino impedir que se pueda representar. Un botón que es "primary y ghost a la vez" es un estado inválido; con booleanas, ese estado existe en el espacio de props y tu código tiene que decidir qué hacer con él (normalmente, un if con prioridades: "si isPrimary gana sobre isGhost"). Con un eje, ese estado no existe: variant no tiene forma de valer dos cosas, así que no hay nada que manejar.
Esto cambia dónde vive la corrección. Con booleanas, la corrección es defensiva y vive en tu lógica: enumeras prioridades, decides desempates, escribes tests para combinaciones que no deberían pasar. Con ejes, la corrección es estructural y vive en la forma de la API: como el tipo de variant es "una de tres opciones", el estado inválido se descarta antes de llegar a tu código —en TypeScript, ni siquiera compila; en runtime, no hay dos props que consultar—. El patrón de variantes es una aplicación directa de este principio a la apariencia de un componente: modelas cada dimensión de la apariencia como un eje con opciones, y con eso las combinaciones sin sentido dejan de ser expresables. Los 20 estados basura del ejemplo no es que estén "bien manejados" con ejes: es que no se pueden ni escribir.
Errores comunes
Agregar una prop booleana por cada apariencia nueva. Qué pasa: llega el botón "danger" y se agrega isDanger; llega el "extra grande" y se agrega isXLarge. Por qué pasa: cada bandera nueva parece un cambio pequeño e inofensivo. Cómo detectarlo: tu Button tiene seis, ocho, diez props booleanas, y para saber cómo se ve hay que leer una maraña de if. Cómo corregirlo: las apariencias de una misma dimensión son valores de un eje, no props nuevas. "danger" es otra opción de variant; "xl" es otra opción de size. Agregar una opción a un eje no multiplica los estados imposibles; agregar una booleana sí —cada una duplica el espacio de combinaciones—.
Manejar las contradicciones con un if de prioridades en vez de eliminarlas. Qué pasa: se aceptan isPrimary e isGhost, y dentro se escribe if (isPrimary) {...} else if (isGhost) {...} para que "gane primary". Por qué pasa: parece que basta con decidir un desempate. Cómo detectarlo: tu componente tiene lógica dedicada a resolver combinaciones que no deberían existir. Cómo corregirlo: no manejes el estado imposible —imposibilítalo—. Con un eje variant, no hay desempate que decidir, porque no hay dos looks a la vez. El if de prioridades es la señal de que modelaste la apariencia con las herramientas equivocadas; cámbialas por un eje y el if desaparece.
Confundir "ejes" con "menos opciones". Qué pasa: se cree que el patrón de variantes es tener pocas apariencias, y que si necesitas muchas, vuelves a las booleanas. Por qué pasa: el ejemplo tiene 3 variantes y 3 tamaños, y parece que la ventaja es la escala pequeña. Cómo detectarlo: justificas volver a booleanas "porque son demasiadas combinaciones". Cómo corregirlo: la ventaja del eje no es tener pocas opciones —es que las opciones de un eje son excluyentes—. Un eje con diez opciones sigue teniendo cero contradicciones; diez booleanas tienen cientos. El patrón escala mejor, no peor, cuantas más apariencias tengas: agregar una opción a un eje es sumar una entrada; agregar una booleana es duplicar el espacio de estados.
Ejercicios
Ejercicio 1 — Booleanas o ejes. Para cada API de props, di si usa booleanas (banderas independientes que pueden contradecirse) o ejes (props excluyentes), y si permite estados imposibles:
- (a)
<Alert isInfo isWarning isError /> - (b)
<Alert tone="info" | "warning" | "error" /> - (c)
<Text isBold isItalic /> - (d)
<Button variant="primary" | "ghost" fullWidth />
Ver solución
- (a) Booleanas, con estados imposibles.
isInfo,isWarning,isErrorson banderas independientes: nada impideisInfo && isError. Debería ser un ejetone. - (b) Eje, sin estados imposibles.
tonees una prop excluyente; el mismo patrón devariant. Correcto. - (c) Booleanas, pero legítimas.
isBoldeisItalicno se contradicen —un texto puede ser negrita y cursiva a la vez—. Aquí las booleanas están bien: son dimensiones independientes y combinables, no opciones excluyentes de un mismo eje. La clave no es "booleana = mal", es "¿las opciones se excluyen?". Negrita y cursiva no. - (d) Mezcla correcta.
variantes un eje (excluyente);fullWidthes una booleana legítima (un botón puede ser primary y ancho, o ghost y ancho —dimensiones independientes—). Combinar ejes para lo excluyente y booleanas para lo independiente es exactamente lo correcto.
La regla: usa un eje cuando las opciones se excluyen (un botón es primary o ghost); usa una booleana cuando la dimensión es independiente y combinable (ancho, deshabilitado, con ícono).
Ejercicio 2 — Cuenta la explosión. Un dev modela el botón con seis booleanas: tres de look (isPrimary, isSecondary, isGhost) y tres de tamaño (isSmall, isMedium, isLarge). (a) ¿Cuántas combinaciones posibles hay (2^N)? (b) Reformulado como dos ejes (variant de 3, size de 3), ¿cuántas combinaciones válidas hay? (c) En una frase, ¿por qué el eje escala mejor?
Ver solución
- (a) 64.
2^6 = 64combinaciones de encendido/apagado, la enorme mayoría contradictorias (dos looks, dos tamaños, o ambos). - (b) 9.
3 × 3= 9 combinaciones, todas válidas. La misma expresividad real (3 looks × 3 tamaños) que las booleanas pretendían dar, pero sin los 55 estados basura. - (c) Porque agregar una opción a un eje suma una entrada, mientras que agregar una booleana duplica el espacio de estados. El eje crece de forma lineal y sin contradicciones; las booleanas crecen exponencialmente y casi todo lo nuevo es imposible.
Ejercicio 3 — Predice la salida. Sin correr nada, di qué imprimiría el segundo bloque (=== por ejes ===) si variant tuviera 4 opciones (primary, secondary, ghost, danger) en vez de 3, dejando size en 3.
Ver solución
combinaciones validas: 12 (4 × 3) y combinaciones contradictorias: 0. Al agregar una opción al eje variant, las combinaciones válidas suben de 9 a 12 —una por cada tamaño de la nueva variante—, y las contradictorias siguen en cero, porque un eje sigue tomando un solo valor sin importar cuántas opciones tenga. Compáralo con las booleanas: pasar de 5 a 6 banderas habría subido las combinaciones de 32 a 64 y disparado las contradictorias. La lección: crecer un eje es barato y seguro; crecer las booleanas es caro y peligroso.
Resumen y siguiente paso
En esta lección aprendiste el corazón del módulo: el patrón de variantes modela la apariencia como ejes —props excluyentes— en vez de booleanas independientes, y con eso hace los estados imposibles inexpresables. Con la perilla frente a los interruptores sueltos viste el porqué: cinco palancas independientes permiten configuraciones que se contradicen; una perilla, no. Y lo mediste: cinco props booleanas dan 32 combinaciones, de las cuales 20 (el 62%) son contradictorias; dos ejes (variant × size) dan 9 combinaciones y cero contradicciones. También viste que las booleanas no son el enemigo per se —isBold/isItalic, fullWidth son dimensiones independientes legítimas—; el enemigo es usar booleanas para opciones excluyentes, que es justo lo que un eje resuelve.
Antes de avanzar deberías poder: explicar por qué un eje no puede contradecirse; distinguir cuándo usar un eje (opciones excluyentes) de cuándo una booleana (dimensión independiente); y decir por qué el eje escala mejor que las booleanas al crecer las apariencias.
La lección 4 formaliza el motor: base + variants + defaultVariants. Ya sabes que variant y size son ejes; ahora vas a construir la estructura que mapea cada opción de cada eje a sus clases, la parte base que todos comparten, y los defaultVariants que responden "¿qué botón sale si no pasas nada?". Vas a ejecutar la resolución completa del Button de Mercado y ver, paso a paso, cómo unas props se convierten en una lista de clases.
Recursos
- cva (
class-variance-authority), "Variants" — cva.style/docs/getting-started/variants. Cómo se declaran los ejes (variants) en la librería real; la formalización de lo que esta lección motivó. En inglés. - React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. Cómo el componente recibe
variantysizecomo props —la entrada de los ejes—. En inglés. - Kent C. Dodds, "Make Impossible States Impossible" — kentcdodds.com/blog/make-impossible-states-impossible. El principio de diseño de APIs que sostiene el patrón de variantes: impedir el estado inválido en vez de manejarlo. En inglés.
- shadcn/ui, "Button" — ui.shadcn.com/docs/components/button. Un
Buttonreal con ejesvariantysize(no booleanas); el patrón de esta lección en producción. En inglés.