wandres.dev
ARQUITECTURA II · Utility-first, CSS-in-JS y el estado del arte

El declive del CSS-in-JS en runtime

Qué hace exactamente una biblioteca de estilos en runtime en cada render, por qué ese coste era estructural, y cómo los componentes de servidor lo dejaron sin salida.

⏱ 18 min

El CSS-in-JS en runtime fue, entre 2016 y 2021, la respuesta mayoritaria a los problemas del CSS global en aplicaciones de componentes, y resolvía de verdad lo que prometía: ámbito automático, estilos que dependen de props, código muerto imposible por construcción. Su declive no vino de un cambio de gustos ni de una campaña en redes. Vino de dos cosas medibles: un coste por render que era consecuencia inevitable del diseño, y una arquitectura de renderizado nueva con la que el modelo era sencillamente incompatible.

🎯 Al terminar esta lección sabrás
  • Detallar los pasos que ejecuta una biblioteca de estilos en runtime en cada render.
  • Explicar por qué el coste de serialización no se podía optimizar, solo reducir.
  • Entender la incompatibilidad estructural con los componentes de servidor.
  • Interpretar correctamente el paso a mantenimiento de las bibliotecas de referencia.

Qué pasa exactamente en cada render

Cuando escribes un componente con estilos en runtime, el navegador ejecuta más trabajo del que la sintaxis sugiere. El ciclo completo, para un solo componente:

  1. Interpolar. La plantilla de estilos contiene funciones que dependen de las props. Hay que ejecutarlas todas con los valores actuales para obtener la cadena de CSS final.
  2. Serializar. Esa cadena hay que normalizarla: expandir abreviaturas, añadir prefijos de vendedor, resolver anidamientos. El resultado es una cadena de CSS plano.
  3. Hashear. Se calcula un hash de la cadena para obtener un nombre de clase determinista y para poder consultar la caché.
  4. Consultar la caché. Si esa combinación exacta ya se insertó, se reutiliza la clase. Si no, sigue.
  5. Insertar. Se añade la regla a la hoja de estilos del documento, normalmente vía la API de la hoja de estilos.
  6. Aplicar. Se pone el nombre de clase resultante en el elemento.

Ese ciclo se ejecuta por componente y por render. Con la caché caliente, los pasos 5 y 6 son baratos y el coste se concentra en los pasos 1 a 4, que ocurren siempre porque no se puede saber si el resultado está en caché sin calcularlo primero.

El problema es que ese trabajo no es proporcional a lo que ha cambiado, sino a cuántos componentes se han renderizado. Una lista de mil filas que solo cambia el orden vuelve a interpolar, serializar y hashear mil veces. Comparado con un className estático, que es una asignación de cadena, la diferencia por elemento es de dos órdenes de magnitud, y en una interfaz con mucho render se nota en el perfil como tiempo de script en el hilo principal.

⚠️
El paso 5 también invalida trabajo del navegador

Insertar una regla nueva en una hoja de estilos obliga al motor a rehacer parte de su preparación de selectores y puede invalidar el estilo calculado de un subárbol. Con la caché caliente ocurre pocas veces, pero ocurre justo cuando aparece contenido nuevo: al abrir un modal, al cargar una página, al montar una vista. Es decir, el coste se concentra exactamente en los momentos en que el usuario está esperando.

Por qué no se podía optimizar del todo

Se intentó, y bastante. Cachés más agresivas, serializadores más rápidos, extracción parcial en compilación de las partes estáticas. Todo eso mejoró los números sin cambiar la conclusión, porque el coste era estructural: nace de la propia promesa del modelo.

La promesa es que el estilo puede depender de cualquier valor de JavaScript en tiempo de ejecución. Si eso es cierto, el CSS final no se puede conocer antes de ejecutar el componente. Y si no se puede conocer antes, hay que calcularlo después, en el navegador, en el hilo principal, mientras el usuario espera. No hay optimización que salve esa implicación; solo se puede reducir la constante.

La salida real llegó por otro camino y se ve claramente en las alternativas modernas: quedarse con la sintaxis y renunciar a la parte dinámica arbitraria, resolviendo lo que sí varía en runtime a través de custom properties. Ese es el tema de la lección siguiente.

Hubo además un intento concreto de mitigación por parte de React: el hook useInsertionEffect, añadido en la versión 18 en 2022, existe específicamente para que las bibliotecas de estilos inserten sus reglas en el momento correcto del ciclo —después del render y antes de que los efectos de layout lean medidas del DOM—. Que el framework añadiera una API dedicada a este problema da la medida de lo serio que era; que varias bibliotecas populares nunca llegaran a adoptarla, e insertaran estilos durante el render, da la medida de por qué el rendimiento no mejoró.

La incompatibilidad que cerró la puerta

El golpe definitivo no fue de rendimiento sino de arquitectura, y tiene dos capas.

El renderizado en servidor por streaming. Para que el HTML enviado desde el servidor llegue ya estilado, la biblioteca tiene que recolectar durante el render todas las reglas generadas e inyectarlas en la respuesta. Eso funciona bien cuando el render es una operación única que produce una cadena completa. Con streaming, el servidor empieza a enviar HTML antes de haber renderizado el resto, y las reglas de los componentes que aún no se han renderizado no se pueden poner en la cabecera porque todavía no existen. Las soluciones —inyectar bloques de estilo intercalados en el flujo— funcionan, pero cuestan bytes, complican la implementación y reintroducen destellos de contenido sin estilar.

Los componentes de servidor. Aquí la incompatibilidad no es de dificultad, es de definición. Un componente de servidor se ejecuta en el servidor, no se envía al cliente y no tiene estado ni contexto de React. Las bibliotecas de estilos en runtime dependen de contexto para el tema: un proveedor en la raíz que distribuye los tokens a todos los componentes. Sin contexto, no hay tema. Y como el estilo se calcula durante el render con valores de JavaScript, tampoco hay forma de generar las clases en servidor y darlas por buenas en cliente.

La consecuencia práctica es que cada componente que use estilos en runtime tiene que marcarse como componente de cliente. Es decir: el modelo de estilos decide la arquitectura de renderizado de tu aplicación, y la decide en la dirección contraria a la que el framework quiere ir. Un componente de presentación puro, sin interactividad, que podría no enviar nada de JavaScript al navegador, acaba enviándolo por el color de su borde.

En marzo de 2025 el mantenedor de la biblioteca de referencia del ecosistema anunció el paso a modo de mantenimiento, citando exactamente esto: incompatibilidad con los componentes de servidor sin la directiva de cliente, y la recomendación explícita de no adoptarla en proyectos nuevos. No fue una moda que pasó; fue una arquitectura que dejó de encajar.

Qué era realmente bueno, y adónde fue

Conviene no tirar lo que sí funcionaba, porque casi todo sobrevive en otras herramientas.

El ámbito automático. Nadie escribía nombres de clase, nadie colisionaba. Eso lo dan hoy los módulos de CSS, los estilos con ámbito de los frameworks de componentes y @scope.

Los estilos junto al componente. Ver el marcado y el estilo en el mismo fichero es una ventaja real de mantenimiento. Lo dan las soluciones de tiempo de compilación con la misma sintaxis.

La imposibilidad de código muerto. Borrar el componente borraba su CSS. Igual en compilación.

Los tipos. Poder escribir estilos en TypeScript, con autocompletado sobre los tokens del tema y errores si te inventas un valor, era una ventaja genuina que el CSS puro no tenía. Es la razón principal por la que las soluciones tipadas de tiempo de compilación tienen usuarios convencidos.

La composición como una función. Una variante de estilo era una función de props a estilos, con toda la expresividad de un lenguaje de programación. Eso se ha reencarnado en las recetas de las herramientas modernas.

La lección no es sobre CSS: es sobre pagar en runtime lo que se puede pagar en build

Lo que ocurrió con el CSS-in-JS en runtime es un caso de estudio de un patrón que vas a ver repetirse en toda tu carrera, y que conviene saber leer antes de que te pase. Una tecnología ofrece una abstracción con una ergonomía notablemente mejor y la paga moviendo trabajo al momento más caro posible: el dispositivo del usuario, en el hilo principal, mientras espera. Durante un tiempo el trato parece bueno, porque el coste está repartido en millones de dispositivos ajenos y la ganancia está concentrada en tu equipo, que la nota todos los días. La corrección llega siempre desde el mismo sitio, y casi nunca desde una discusión sobre rendimiento: llega cuando cambia la arquitectura del entorno y la abstracción deja de encajar en él. Aquí fueron los componentes de servidor, que replantearon dónde se ejecuta el código; el coste por render llevaba años documentado y no había bastado para mover a nadie. La regla que conviene interiorizar es que una abstracción que exige ejecutar código en el cliente para producir algo que no depende de datos del cliente es deuda con fecha de vencimiento desconocida, aunque hoy funcione. El color de un botón no depende del dispositivo de nadie; calcularlo allí siempre fue trabajo movido al peor sitio. Aplica ese mismo filtro a lo que uses ahora —enrutado, internacionalización, formularios, animación— y verás cuáles de tus dependencias están en la misma posición.