Saltar al contenido
Ende Sun
Volver al blog

Frontend y design systems

Un design system en React para apps Symfony: tokens, CSS Modules y convivencia con Bootstrap

6 min de lectura

Un design system en React para apps Symfony: tokens, CSS Modules y convivencia con Bootstrap

En Osunasport trabajo en la plataforma web de MasMusculo, un producto de gestión de gimnasios construido con Symfony y Twig. Con el tiempo, la interfaz había acumulado muchas versiones ligeramente distintas de las mismas piezas: botones, badges, tarjetas, alertas. Cuando decidimos crear un design system de verdad, el primer inventario tenía unas 80 entradas. La mayoría no eran componentes distintos, sino variantes de lo mismo.

En este artículo cuento las decisiones que hicieron esa librería utilizable desde páginas Symfony renderizadas en servidor, y la parte menos vistosa: cómo la consume un equipo en el día a día sin romperle la rama a nadie.

Primero, consolidar: de ~80 entradas a ~35 componentes

El paso más valioso ocurrió antes de escribir código. El inventario tenía entradas como "botón píldora primario", "botón primario pequeño", "botón destructivo" o "botón de volver". Tratadas como componentes separados, eso son 80 archivos que mantener.

En su lugar, las variantes se expresan con props. Un único Button recibe un variant, un size y un href opcional; si hay href, renderiza un enlace en lugar de un botón. La misma idea se aplica a badges, alertas, avatares o días de calendario. El resultado fueron unos 35 componentes reales, organizados en átomos, moléculas y organismos.

Todos los tipos de props compartidos viven en un único archivo, y nadie los redefine localmente:

export type Size = 'sm' | 'md' | 'lg'
export type ButtonVariant = 'primary' | 'outline' | 'destructive' | 'ghost' | 'success'
export type AlertVariant = 'error' | 'success' | 'warning' | 'info'

Unas pocas reglas mantienen la API predecible: size siempre es sm | md | lg, la apariencia visual siempre es variant (nunca type), el estado semántico es state, disabled es un booleano y nunca una variante, y el contenido siempre entra por children.

Tokens de diseño con prefijo propio

Cada token de diseño es una variable CSS con el prefijo obligatorio --ds-:

--ds-color-cta: #000000;
--ds-font-family: 'Example Sans', sans-serif;
--ds-radius-pill: 25px;

Bootstrap usa --bs-, así que los dos conjuntos de variables nunca se pisan. Los tokens se agrupan por propósito (color, tipografía, radios, espaciado, sombras y filtros para colorear iconos SVG), y el tema se cambia con una clase en <body> (dark o white), donde cada tema define sus propios valores para los mismos tokens.

CSS Modules, en camelCase

Los estilos de cada componente son CSS Modules compilados con Vite, que genera nombres de clase con hash. Eso da aislamiento real: una clase llamada badge dentro de un módulo nunca puede chocar con el .badge global de Bootstrap. La única regla es no usar los nombres globales de Bootstrap (btn, card, modal, row, col…) directamente como className de un elemento.

Las clases se escriben en camelCase (.ctaPill, .withPromo), porque desde TypeScript styles.ctaPill se lee mejor y es menos propenso a errores que styles['cta-pill']. Cada módulo sigue la misma forma: una clase base .root, luego variantes de apariencia, variantes de tamaño y estados.

Librerías de terceros detrás de wrappers

Gráficas, lectura de QR o selectores de fecha vienen de librerías externas, pero nunca se usan directamente en las plantillas. Cada una vive detrás de un pequeño componente wrapper que expone solo las props que necesita nuestro caso de uso y aplica los tokens de diseño por dentro. Un indicador de aforo, por ejemplo, recibe un porcentaje y dos umbrales; decidir si se muestra en verde, ámbar o rojo ocurre dentro del wrapper. Las librerías pequeñas y de uso exclusivo van en dependencies; las grandes, en peerDependencies.

Montar componentes React desde Twig

Los proyectos Symfony no son aplicaciones de una sola página, así que los componentes se montan donde la plantilla los pide. Un único archivo de entrada mantiene un COMPONENT_MAP, idéntico en todos los proyectos Symfony que usan la librería. El nombre en el mapa es el nombre del componente en TypeScript y el valor del atributo data-react-component:

const COMPONENT_MAP = { Button, Badge, AlertBanner, NavCard /* ... */ }

document.querySelectorAll('[data-react-component]').forEach((el) => {
  const Component = COMPONENT_MAP[el.dataset.reactComponent]
  if (!Component) return console.warn('Component not found in COMPONENT_MAP')
  const props = el.dataset.props ? JSON.parse(el.dataset.props) : {}
  createRoot(el).render(<Component {...props} />)
})

En Twig, las props se pasan como JSON. Un detalle nos costó tiempo: el atributo debe ir con comillas simples, porque Twig escapa las comillas dobles a &quot; y entonces JSON.parse falla.

<div data-react-component="Button"
     data-props='{{ {"variant": "primary", "href": path("home"), "children": "Empezar"} | json_encode | raw }}'></div>

Versionado con semver

La librería sigue versionado semántico: patch para arreglos de CSS o correcciones de tokens, minor para componentes nuevos o props retrocompatibles, y major para cualquier cosa que rompa a quien la consume, como renombrar un componente, una prop o un token. Antes de crear un componente nuevo hay una lista corta de comprobaciones: ¿podría cubrirlo un componente existente con una prop más? ¿Necesita variant o state desde el primer día? ¿Se exporta desde el barrel y está añadido al COMPONENT_MAP?

Consumirla con pnpm: link o git

La librería vive en su propio repositorio, y la app Symfony puede apuntar a ella de dos formas:

  • Enlace local (link:../design-system): un symlink a un clon situado junto a la app. Los cambios aparecen al instante, ideal cuando estás editando el propio design system. Vite recompila en modo watch y Webpack Encore detecta el cambio a través del symlink.
  • Git (github:example-org/design-system#develop): pnpm la descarga. Es lo que necesitan los compañeros que solo trabajan en la app, y el modo con el que se commitea la rama, para que cualquiera pueda clonar y hacer pnpm install sin clonar también la librería.

Las dos no pueden convivir: package.json y el lockfile reflejan una única fuente. Dos scripts alternan entre ellas:

pnpm run theme:link && pnpm install   # editar el design system en vivo
pnpm run theme:git  && pnpm install   # volver al estado commiteado

Por qué se commitea el dist compilado

pnpm 10 bloquea que las dependencias instaladas desde git ejecuten scripts de build. Un script prepare que compilara al instalar resultó frágil entre versiones de pnpm. La solución pragmática fue commitear el dist compilado en el repositorio de la librería, de modo que quien la consume nunca compila nada al instalar. El coste es explícito: cada cambio en la librería requiere reconstruir y commitear el dist.

Hacer imposible subir el enlace local

El riesgo del enlace local es evidente: package.json y el lockfile aparecen como modificados y, tarde o temprano, alguien los sube. Dos capas lo evitan:

  1. Invisibilidad. Al pasar a modo link, el script marca ambos archivos con git update-index --skip-worktree, así que desaparecen de git status. Al volver a modo git, se desmarcan.
  2. Una red de seguridad. Un hook pre-commit rechaza cualquier commit que contenga la ruta local link:. Se instala solo mediante core.hooksPath desde un script prepare, así que se aplica a todo el equipo sin configuración manual.

skip-worktree es por clon, así que cada desarrollador que quiera el modo link lo activa una vez. Si un git pull se queja de esos dos archivos, volver a modo git los libera.

Lo que mantendría

Las dos ideas que repetiría en cualquier proyecto parecido son consolidar las variantes en props antes de escribir componentes, y tratar el flujo de trabajo del equipo (link frente a git, hooks, builds commiteados) como parte del propio design system. Los componentes solo sirven si el equipo puede consumirlos sin fricción.