Módulo 7: Primitives And Component Libraries

El modelo shadcn: copiar el código

Descripción

Hasta ahora asumiste que "usar una primitiva" significa instalar un paquete de npm@radix-ui/react-dialog— e importarlo, como cualquier otra dependencia. Eso es cierto, y es una forma válida de trabajar. Pero shadcn/ui, el proyecto que popularizó este modelo en el ecosistema React, propone algo distinto para la capa de estilo: en vez de instalar un Dialog ya vestido como dependencia, ejecutas un comando (npx shadcn add dialog) que copia el código fuente del componente directamente a tu repo —un archivo components/ui/dialog.tsx, con la primitiva de Radix por debajo y tus clases de Tailwind ya aplicadas—. Ese archivo, desde el segundo en que se copia, es tuyo: lo editas como cualquier otro archivo de tu proyecto, sin fork, sin pull request a un paquete externo, sin esperar a que una nueva versión te dé la opción que necesitas.

Conexión con el módulo. Esta lección resuelve la pregunta "¿de dónde sale el Dialog.jsx vestido de la lección 4?" con la respuesta real que usa la industria hoy: no lo escribes completamente desde cero (eso sería reinventar la primitiva) ni lo instalas pre-armado como una caja negra (eso te quitaría el control que ganaste vistiéndolo con tus tokens). Lo generas una vez, con shadcn, y de ahí en más es código tuyo, igual de editable que el Button que construiste a mano en el módulo 5.

Una analogía: comprar el mueble desarmado, no el terminado

Piensa en dos formas de conseguir un mueble. La primera: compras uno ya armado y entregado, de una tienda que lo fabrica, lo empaca y te lo entrega terminado. Es rápido, pero si mañana quieres cambiarle el color, agregar un cajón o mover una repisa, no puedes —el mueble es del fabricante en el sentido de que solo él sabe cómo está construido por dentro, y cualquier modificación seria requiere deshacerlo o comprar uno nuevo—. La segunda: compras un mueble desarmado, en piezas, con instrucciones, y lo ensamblas tú en tu casa. Desde el momento en que lo armas, es completamente tuyo: si quieres cambiarle el tornillo, pintarlo de otro color, o agregarle una repisa extra, lo haces directamente, porque tienes el mueble entero frente a ti, no una caja sellada.

Instalar una librería de componentes pre-estilada (una dependencia con su propio look, del tipo que la lección 6 va a nombrar) es el mueble ya armado y entregado: rápido, pero cerrado. El modelo shadcn es el mueble desarmado: el comando npx shadcn add dialog te entrega las piezas —la primitiva de Radix, las clases de Tailwind, la config de variants()— ya ensambladas en un archivo, en tu propio repo, listo para que sigas ajustándolo con tus propias manos, sin pedirle permiso a nadie.

Guarda la imagen: copiar el código es comprar el mueble desarmado y ensamblarlo en tu casa —lo armas una vez, y de ahí en adelante es completamente tuyo.

Ejemplo trabajado: editar tu propio archivo, sin fork

La prueba más concreta de "es tuyo" es que puedes editarlo directamente, sin ningún paso intermedio. Tomamos la config de Dialog.Content que shadcn generaría —la misma idea de la lección 4, con variants()— y le hacemos un cambio de marca real: Mercado decide que sus modales ahora llevan esquinas más redondeadas y una sombra más marcada.

// L5 - copiar el codigo (shadcn) significa que el archivo es TUYO: lo editas directo, sin fork.
// mismo variants() de L4; lo que cambia es que ahora tocamos la config como quien edita su propio archivo.

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(' ');
}

// esta es components/ui/dialog.tsx tal como la genero `npx shadcn add dialog` (ya con tokens de Mercado).
const dialogContentVariants = {
  base: 'rounded-md bg-surface text-foreground shadow-lg p-6',
  variants: { size: { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg' } },
  defaultVariants: { size: 'md' },
};

console.log('=== antes: la config generada, sin tocar ===\n');
console.log('size="md" ->', variants(dialogContentVariants, {}));

// Mercado rebrandea: esquinas mas redondeadas y una sombra mas marcada.
// como el archivo es tuyo, lo editas directo -- no hay "override", no hay theme API que pelear.
dialogContentVariants.base = 'rounded-2xl bg-surface text-foreground shadow-2xl p-6';

console.log('\n=== despues: edite el archivo mio (rounded-md -> rounded-2xl, shadow-lg -> shadow-2xl) ===\n');
console.log('size="md" ->', variants(dialogContentVariants, {}));
console.log('\nno hubo fork, no hubo pull request a un paquete externo: es tu componente, en tu repo.');

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== antes: la config generada, sin tocar ===

size="md" -> rounded-md bg-surface text-foreground shadow-lg p-6 max-w-md

=== despues: edite el archivo mio (rounded-md -> rounded-2xl, shadow-lg -> shadow-2xl) ===

size="md" -> rounded-2xl bg-surface text-foreground shadow-2xl p-6 max-w-md

no hubo fork, no hubo pull request a un paquete externo: es tu componente, en tu repo.

Lee el "antes" y el "después" como una simulación exacta de lo que pasaría en tu editor. La primera corrida muestra la config tal como shadcn la generórounded-md, shadow-lg— sin tocar. La segunda muestra el resultado después de un cambio de dos palabras en el base: rounded-mdrounded-2xl, shadow-lgshadow-2xl. No hubo una prop de tema que buscar, ni una variable de configuración global, ni una versión nueva del paquete que instalar y esperar que soportara "esquinas más redondeadas" como opción. Fue exactamente el mismo tipo de edición que harías en el buttonConfig del módulo 5: abrir el archivo, cambiar la cadena, guardar.

Compáralo con la alternativa —una dependencia pre-estilada instalada desde node_modules—. Ahí, para lograr el mismo cambio, normalmente tienes tres caminos, y ninguno es tan directo: (1) buscar si la librería expone una prop de theme o variant que cubra justo lo que quieres —y si no existe esa opción exacta, no hay forma de lograrlo sin salirte del sistema de la librería—; (2) sobreescribir con CSS más específico o !important, peleando contra las clases que la librería ya trae; o (3) hacer un fork del paquete completo, mantenerlo tú, y perder las actualizaciones automáticas. El modelo de copiar el código elimina esa negociación: el archivo vive en tu repo, así que la "personalización" es simplemente "editar", como cualquier otro código que escribes.

Profundización: el tradeoff real, sin idealizar ninguno de los dos lados

Ningún modelo es gratis, y esta lección sería deshonesta si no nombrara el costo del que elegiste. Copiar el código (shadcn) te da control total y cero fricción para personalizar, pero a cambio, eres responsable de mantener ese archivo: si Radix publica una versión nueva con un bug corregido, no te llega automáticamente por npm update —tienes que volver a generar el componente o aplicar el fix a mano—. Con diez o veinte componentes copiados, eso es responsabilidad real, aunque manejable (son archivos de tu propio repo, versionados, revisables en cualquier pull request).

Instalar una dependencia pre-estilada (un paquete completo de componentes con su propio look, como Material UI o Chakra UI) te da lo opuesto: actualizaciones automáticas, menos código propio que mantener, y consistencia garantizada por el mantenedor del paquete —a cambio de que personalizar profundamente sea, en el mejor caso, trabajoso, y en el peor, imposible sin pelear contra el sistema de la librería—.

Hay una tercera opción, la que este módulo usó en las lecciones 3 y 4 y la que la lección 6 va a formalizar: instalar la primitiva sin estilo como dependencia (@radix-ui/react-dialog, un paquete real de npm) y copiar únicamente la capa visual con shadcn o a mano. Esto es lo mejor de los dos mundos para el caso más común: el comportamiento accesible —la parte difícil de acertar y la que rara vez necesitas modificar— sí la mantiene el equipo de Radix, con actualizaciones normales de npm; el estilo —la parte que cambia con cada rebrand— es 100% tuyo, sin ninguna fricción. Es la combinación que Mercado usa en el proyecto (L8): Radix como dependencia real en el package.json, tus clases copiadas y editables en tu propio archivo.

Tres formas de conseguir un Dialog, y quien mantiene que:

  1) libreria pre-estilada (dependencia completa)
     mantiene: TODO el mantenedor externo   |  personalizas: con dificultad
                                             |
  2) copiar el codigo (shadcn: primitiva + estilo, ambos copiados)
     mantiene: TU (todo el archivo)         |  personalizas: sin friccion
                                             |
  3) primitiva como dependencia + estilo copiado  <- lo que usa este modulo
     mantiene: Radix (comportamiento)       |  personalizas: sin friccion (el estilo es tuyo)
             + TU (las clases)              |

Errores comunes

Creer que "copiar el código" significa "copiar y pegar de un tutorial sin entender". Qué pasa: se interpreta el modelo shadcn como una forma más de hacer Ctrl+C/Ctrl+V de internet sin revisar qué hace el código. Por qué pasa: "copiar" suena informal, como si fuera menos riguroso que "instalar correctamente". Cómo detectarlo: nadie en tu equipo leyó el archivo generado antes de usarlo. Cómo corregirlo: el comando de shadcn genera código legible, auditable, del mismo nivel de calidad que escribirías tú —usa la misma primitiva de Radix y el mismo cva que ya conoces—. "Copiar" aquí es un mecanismo de distribución de código fuente, no un atajo de calidad menor; de hecho, exige más responsabilidad, porque el mantenimiento pasa a ser tuyo.

Copiar un componente y nunca más revisar si Radix publicó un fix de accesibilidad. Qué pasa: se copian veinte componentes al arrancar el proyecto y nunca se vuelve a mirar si la primitiva de la que dependen tuvo actualizaciones importantes (un bug de foco corregido, por ejemplo). Por qué pasa: una vez copiado, el componente "se siente terminado" y sale del radar. Cómo detectarlo: tu package.json fija @radix-ui/react-dialog en una versión de hace un año, sin revisiones. Cómo corregirlo: recuerda el modelo mixto de la profundización — la primitiva (@radix-ui/react-dialog) sigue siendo una dependencia real en tu package.json, y esa sí la actualizas con npm update como cualquier otra. Lo que copiaste es la capa de estilo alrededor; el comportamiento sigue mantenido por Radix, y sigue siendo tu responsabilidad mantenerlo actualizado.

Rechazar shadcn por pensar que instala "una librería más" en el bundle. Qué pasa: alguien evita usar shadcn asumiendo que agrega peso extra a la aplicación, como una librería de componentes completa. Por qué pasa: el nombre "shadcn/ui" suena a librería, y la CLI se instala como herramienta de desarrollo. Cómo detectarlo: revisas el bundle final buscando un paquete shadcn y no lo encuentras —porque no lo hay—. Cómo corregirlo: shadcn no es una dependencia en producción; es una CLI que corres una vez, en desarrollo, para generar código que copia a tu repo. Lo único que termina en tu bundle es exactamente el JSX y las clases que ves en el archivo generado —el mismo peso que si lo hubieras escrito tú a mano—, más la primitiva de Radix que sí instalaste como dependencia real.

Ejercicios

Ejercicio 1 — Elige el modelo. Para cada situación, di si conviene copiar el código (shadcn) o instalar una librería pre-estilada como dependencia completa:

  • (a) Un equipo con un sistema de diseño de marca muy específico, que va a personalizar cada componente a fondo.
  • (b) Un prototipo interno de una semana, sin necesidad de que se vea "de marca".
  • (c) Un componente que el equipo va a necesitar auditar línea por línea por regulación de la industria.
Ver solución
  • (a) Copiar el código. Personalización a fondo es exactamente el caso donde tener el archivo en tu repo, sin pelear contra el theme API de un paquete externo, gana.
  • (b) Librería pre-estilada. Para un prototipo de una semana sin exigencias de marca, la velocidad de "instalar y usar" gana sobre el control que no vas a necesitar.
  • (c) Copiar el código. Auditar línea por línea requiere que el código esté en tu repo, versionado, revisable en tus propios pull requests — exactamente lo que copiar el código te da y una dependencia externa no.

Ejercicio 2 — Predice la salida. Sin correr nada: si en el ejemplo trabajado, en vez de cambiar base, agregaras un nuevo valor al eje sizexl: 'max-w-2xl'— sin tocarlo como defaultVariants, ¿qué imprimiría variants(dialogContentVariants, { size: 'xl' }), usando la config ya editada (rounded-2xl, shadow-2xl)?

Ver solución

Imprimiría: rounded-2xl bg-surface text-foreground shadow-2xl p-6 max-w-2xl. El base ya editado (rounded-2xl, shadow-2xl) se mantiene igual —esa edición ya está guardada en la config—, y como esta vez sí pasaste size: 'xl' explícitamente, el motor no cae en el default: toma directo max-w-2xl del nuevo valor agregado al eje. El ejercicio confirma que editar base y agregar un valor a un eje son cambios independientes —tocaste el primero en el ejemplo, el segundo aquí, y ninguno interfiere con el otro—.

Ejercicio 3 — El modelo mixto. Explica, en tus propias palabras, por qué "instalar la primitiva de Radix como dependencia, pero copiar el estilo con shadcn" es distinto a elegir entre "todo dependencia" o "todo copiado".

Ver solución

Es distinto porque separa las dos responsabilidades del componente —comportamiento y apariencia— y le asigna a cada una el modelo que más le conviene, en vez de tratarlas como un paquete único. El comportamiento (foco, teclado, roles ARIA) es la parte difícil de acertar, cambia poco, y te beneficia recibir sus actualizaciones automáticamente — por eso sigue siendo una dependencia real (@radix-ui/react-dialog en tu package.json, actualizable con npm update). La apariencia (colores, tamaños, tokens) cambia todo el tiempo con la marca, y ahí te conviene control total sin fricción — por eso esa parte se copia y se edita libremente. "Todo dependencia" te ataría la apariencia a un paquete externo; "todo copiado" te haría mantener también el comportamiento, que es justo lo que no quieres reinventar. El modelo mixto usa cada mecanismo donde rinde más.

Resumen y siguiente paso

En esta lección respondiste de dónde sale el componente vestido de la lección 4: shadcn/ui copia el código fuente de un componente —primitiva de Radix más tus clases de Tailwind— directamente a tu repo, y desde ese momento el archivo es tuyo: lo editas sin fork, sin pull request externo, sin esperar una versión nueva. Con el mueble desarmado viste por qué eso importa: lo armas una vez, y de ahí en más lo modificas con tus propias manos. Y lo comprobaste ejecutando: la misma config, editada con dos palabras (rounded-2xl, shadow-2xl), cambió su salida al instante, sin ningún paso intermedio.

Antes de avanzar deberías poder: explicar la diferencia entre "copiar el código" y "instalar una dependencia pre-estilada" con tus propias palabras; nombrar el tradeoff real de cada modelo (quién mantiene, qué tan fácil es personalizar); y describir el modelo mixto que usa este módulo —primitiva como dependencia, estilo copiado—.

Ya sabes cómo llega el código a tu repo. La lección 6 responde la pregunta que viene antes de todo esto: ¿este componente necesitaba una primitiva, para empezar? No todo merece Radix ni shadcn — un Badge simple no tiene comportamiento complejo que resolver. Vas a construir un criterio concreto para decidir cuándo construir tú mismo y cuándo apoyarte en una primitiva.

Recursos

  • shadcn/ui, "Introduction" — ui.shadcn.com/docs. La explicación oficial del modelo "esto no es una librería de componentes; es una colección de componentes reutilizables que copias a tu proyecto". En inglés.
  • shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Cómo el componente generado se conecta con tus variables CSS/tokens desde el primer momento. En inglés.
  • Radix Primitives, "Introduction" — radix-ui.com/primitives/docs/overview/introduction. La pieza que sigue siendo dependencia real en el modelo mixto de esta lección. En inglés.
  • cva (class-variance-authority), documentación oficial — cva.style/docs. La forma real de la config que este ejemplo edita a mano con variants(). En inglés.