wandres.dev
EL COMPILADOR DE SOLID · jsx-dom-expressions

Por qué compilar vence a un runtime: sin VDOM ni diffing

La economía que separa a Solid de React. Por qué un framework de runtime con virtual DOM paga en cada actualización un coste proporcional al tamaño de la vista —reconstruir el árbol, diferenciarlo y parchear—, mientras que Solid, al mover ese análisis al build, paga un coste proporcional al tamaño del cambio. Por qué desaparecen `memo`, `useMemo` y `useCallback`, cómo se compara con Svelte, Marko y el React Compiler, y por qué compilar no es un truco de rendimiento sino un cambio de contrato.

⏱ 15 min

Que Solid no tenga virtual DOM no es un detalle de implementación: reordena toda la economía del framework. Un runtime con VDOM debe descubrir en cada actualización qué cambió, y para eso reconstruye y compara árboles; un compilador lo descubre una sola vez, en el build, y emite código que ya lo sabe. Esta lección explica por qué mover el trabajo del tiempo de ejecución al tiempo de compilación hace que Solid rinda sin que lo optimices, por qué mueren useMemo y compañía, y por qué no está solo en esta apuesta.

🎯 Al terminar esta lección sabrás
  • Analizar el coste por actualización de un runtime con VDOM frente al de la reactividad de grano fino.
  • Entender por qué el trabajo se traslada del runtime al build y qué gana el usuario final.
  • Explicar por qué en Solid desaparecen memo, useMemo, useCallback y las listas de dependencias.
  • Situar a Solid frente a otros compiladores —Svelte, Marko— y frente al React Compiler.

El coste de un runtime con VDOM

En React, cuando un estado cambia, el componente se vuelve a ejecutar: reconstruye su árbol de elementos con createElement, lo compara con el árbol anterior para hallar las diferencias, calcula el conjunto mínimo de parches y los aplica al DOM. El virtual DOM es una apuesta razonable —recomponer y comparar en JavaScript suele ser más barato que tocar el DOM a ciegas—, pero su coste crece con el tamaño de la vista: aunque solo cambie un dato, hay que recorrer y recomparar la porción del árbol afectada para averiguar qué mover. Todo el aparato de memo, useMemo, useCallback y las listas de dependencias nace para podar ese trabajo, y su gestión recae en ti.

// React: cada cambio re-ejecuta el componente y reconcilia su arbol
function Lista({ items }) {
  const total = useMemo(() => items.reduce(suma, 0), [items]); // poda manual
  return <p>{total}</p>; // recalculado y rediferenciado en cada render
}

Ese useMemo es sintomático: es trabajo que existe únicamente para evitar otro trabajo. La lista de dependencias [items] es una promesa que tú firmas y que el runtime no verifica; si te equivocas —olvidas una dependencia o incluyes una de más— el bug es silencioso. El VDOM compra un modelo mental cómodo, la UI como función pura del estado, a cambio de un impuesto de recomputación que tú administras a mano.

Y el impuesto no se paga solo al actualizar. Cada render reconstruye el árbol de elementos del componente aunque el resultado sea idéntico, y ese árbol efímero es basura que el recolector barrerá después. Por eso las apps grandes acaban salpicadas de memo, useMemo y useCallback no por elegancia sino por defensa. La consecuencia cultural es enorme: en React, parte del oficio es aprender a no recomputar; en Solid ese oficio no existe, porque no hay recomputación de la que defenderse.

// React defensivo: cada pieza envuelta para no recomputar
const filas = useMemo(() => items.map(fila), [items]);
const onPick = useCallback((id) => elegir(id), []);
// ...y un React.memo en cada hijo para cortar la propagacion del render

Nada de ese andamiaje tiene equivalente en Solid, y su ausencia no es una carencia: es la señal de que el problema que resuelve no llega a existir.

El coste de Solid: proporcional al cambio

Solid rechaza la premisa. No hay componente que se re-ejecute ni árbol que comparar: cuando un signal muta, despierta solo a sus suscriptores directos —los efectos e insert que lo leyeron— y a nadie más. El coste de una actualización crece con el tamaño del cambio, no con el de la vista. El grafo ya sabe, por construcción en tiempo de compilación, quién depende de qué; no hay nada que descubrir en caliente.

flowchart TD
subgraph Runtime con VDOM
  R1[cambia el estado] --> R2[re ejecuta el componente]
  R2 --> R3[reconstruye arbol de elementos]
  R3 --> R4[diff contra el arbol previo]
  R4 --> R5[parche al DOM]
  R5 --> R6[coste crece con la vista]
end
subgraph Solid compilado
  S1[cambia un signal] --> S2[despierta suscriptores directos]
  S2 --> S3[actualiza esos nodos]
  S3 --> S4[coste crece con el cambio]
end

La consecuencia práctica es que una vista puede ser enorme y las actualizaciones seguir siendo diminutas. Da igual que la página tenga diez nodos o diez mil: un signal que muta toca exactamente lo que depende de él. Y lo que en React exigía un useMemo, en Solid es simplemente una función que se rastrea sola.

// Solid: derivacion sin memoria manual; el grafo la rastrea exacto
function Lista(props: { items: () => number[] }) {
  const total = () => props.items().reduce(suma, 0); // se relee solo si items cambia
  return <p>{total()}</p>;
}

No hay lista de dependencias que firmar ni que equivocar: total se suscribe a lo que lee, exactamente y sin tu intervención. No es que Solid compare más rápido; es que no compara.

Ese mismo componente, compilado, no contiene ni rastro de un reconciliador: ni createElement, ni árbol previo que guardar, ni bucle de comparación.

// Solid: el <p> reactivo, sin nada que reconciliar
const _tmpl$ = _$template(`<p>`);
const _el$ = _tmpl$();
_$insert(_el$, total);   // un efecto de render envuelve total y reescribe el texto

Cuando total cambia, el efecto que lo envuelve reescribe un único nodo de texto. No se vuelve a ejecutar el componente, no se reconstruye el p, no se compara nada con nada. El grafo ya sabía, desde el build, que ese texto —y solo ese texto— dependía de total.

La misma lógica escala a lo grande. En una lista renderizada con For, cambiar un elemento no vuelve a ejecutar la lista ni recorre los demás: el efecto de esa fila —y solo esa fila— reescribe su nodo. Donde un runtime con VDOM recorrería la colección para hallar la fila cambiada, Solid ya tiene, desde que la creó, un puntero directo a ella.

<For each={items()}>{(item) => <Fila dato={item} />}</For>

Cada Fila es su propio ámbito reactivo con su propio efecto; el For solo crea, mueve o destruye filas cuando la lista cambia de forma, y no toca en absoluto las que permanecen.

El trabajo se hace en el build

Aquí está la idea que lo unifica todo. Un framework de runtime debe descubrir, en cada actualización, qué cambió y dónde; un compilador lo descubre una vez, en el build, y emite código que ya lleva esa respuesta cableada. Pagas el análisis en tiempo de compilación —una máquina, una vez— y tus usuarios reciben instrucciones especializadas que no tienen que razonar sobre nada. Por eso el runtime de Solid es minúsculo y, a diferencia del coste de reconciliar, no crece con la complejidad de la app: la complejidad se resolvió antes de enviar el código.

Esto se refleja en el bundle. El runtime reactivo de Solid es diminuto y, lo más importante, no crece con el número de componentes: añadir pantallas suma plantillas y efectos, no capas de framework. Un runtime con VDOM, en cambio, carga siempre con su reconciliador como coste fijo, y las herramientas para optimizarlo son código que también hay que descargar y ejecutar. Menos framework en tiempo de ejecución significa menos JavaScript que parsear en el arranque, justo cuando el usuario más lo nota.

Hay un matiz honesto: compilar no es gratis. El coste se desplaza a tu pipeline —el build tarda algo más y el output es específico de cada plantilla, no reutilizable como un runtime genérico—. Pero es un intercambio casi siempre favorable, porque el build lo paga una máquina una vez y el runtime lo pagan todos los usuarios en cada interacción. Mover trabajo del segundo grupo al primero es una de las mejores palancas de rendimiento que existen.

📝
Grano fino y compilado son dos ejes, no uno

Conviene no confundir dos preguntas. Una es cuándo se hace el trabajo —en runtime o en build—; otra es qué grano tiene la reactividad —el componente entero o el valor individual—. Son ejes independientes: hubo librerías de grano fino sin compilador, como MobX o las primeras versiones de Knockout, y también compiladores de grano grueso. Solid ocupa la esquina extrema de ambos ejes: máximo empuje al build y grano máximo. Esa combinación, y no cada eje por separado, es lo que produce a la vez su perfil de rendimiento y sus rarezas de uso.

🚫

Cero diffing

Nunca se compara un árbol con otro. El efecto que observa un signal actualiza su nodo directamente cuando ese signal cambia.

🧹

Sin memo manual

No hay useMemo, useCallback ni arrays de dependencias: el tracking es automático y exacto, no algo que tú ajustes.

🪶

Arranque ligero

Crear la UI es clonar plantillas y registrar efectos, sin materializar un grafo de objetos que describa el DOM.

🎯

Coste proporcional

El trabajo de una actualización mide el tamaño del cambio, no el de la vista. Escala por construcción.

No es el único que compila

Solid no inventó compilar la vista; sí lleva la reactividad de grano fino hasta el final. Conviene situarlo entre sus vecinos para no confundir “compilar” con “ser como Solid”.

  • Svelte compila desde siempre, pero durante años su reactividad fue a nivel de componente, con marcado de sucio y actualización de instancia. Svelte 5, con runes, adoptó signals y se acercó al modelo de grano fino de Solid: dos caminos que convergen.
  • Marko es un compilador orientado a streaming y resumabilidad, con una partición estático/dinámico aún más agresiva pensada para el servidor.
  • El React Compiler —antes React Forget— no elimina el VDOM: inserta automáticamente la memoización que antes escribías a mano, para que dejes de colocar useMemo y useCallback. Optimiza el runtime; no lo sustituye. React sigue reconciliando; solo lo hace podando mejor.

La postura de Solid es distinta en el fondo, no en el grado: grano fino desde el primer día, compilador más un runtime reactivo diminuto, y ningún árbol virtual en ninguna parte. Cuando compares frameworks, la pregunta útil no es “compila o no compila”, sino “dónde vive el trabajo de saber qué cambió”: en cada frame del usuario, o una sola vez en tu máquina de build.

Dicho de otro modo, todos estos frameworks reparten el mismo trabajo entre dos momentos —build y runtime— y se distinguen por cuánto empujan hacia el build y cuánto grano de reactividad conservan. Solid empuja casi todo al build y conserva el grano más fino posible; React empuja poco y compensa con un runtime sofisticado; Svelte y Marko se sitúan, cada uno a su manera, del lado del compilador.

ℹ️
El React Compiler optimiza el runtime; no lo elimina

Es fácil oír “React ya tiene compilador” y concluir que la distinción se borró. No es así. El React Compiler analiza tus componentes e inserta por ti la memoización que antes escribías a mano, de modo que dejas de colocar useMemo y useCallback. Es un avance real de ergonomía y de rendimiento, pero opera dentro del modelo: sigue habiendo virtual DOM, sigue habiendo re-render y reconciliación, solo que mejor podados. Solid no poda la reconciliación: la suprime. Son dos respuestas a la misma pregunta —cómo evitar trabajo de más—, una desde dentro del runtime y otra eliminando la causa en el build.

Compilar no es un truco de rendimiento: es un cambio de contrato

Es tentador archivar todo esto como “Solid es más rápido porque compila”, pero eso confunde el síntoma con la causa. Lo que cambia al compilar la vista no es la velocidad sino el contrato entre tú y el framework. En el contrato del runtime —el de React— tú declaras la UI como una función pura del estado y el framework se compromete a, en cada cambio, volver a ejecutar esa función, reconstruir el árbol que describe y reconciliarlo con el DOM; a cambio de esa comodidad conceptual pagas un coste que crece con lo que muestras y heredas la responsabilidad de podarlo con memoización. En el contrato del compilador —el de Solid— tú declaras la misma UI, pero el framework se compromete a leer tu intención una sola vez, en el build, y a emitir el código imperativo mínimo que la cumple: clonar esta plantilla, insertar aquí, envolver esta asignación en un efecto. El componente ya no es una función que se repite sino una fábrica que se ejecuta una vez y deja tras de sí un grafo de dependencias que se propaga solo. De ese cambio de contrato se derivan, en cascada, todas las diferencias que parecían inconexas: la ausencia de virtual DOM, la muerte de useMemo, el componente que corre una vez, el coste que mide el cambio y no la vista, el runtime que no engorda con la app. No memorices las diferencias como una lista de trivia; deriva todas del contrato y las recordarás para siempre, porque entenderás que no son elecciones sueltas sino teoremas del mismo axioma: el trabajo de saber qué cambió pertenece al build, no al usuario.

⚔️ Mide el cambio de contrato
  1. Escribe en React un contador dentro de una lista grande y cuenta, conceptualmente, cuánto árbol se reconcilia al pulsar +1; luego hazlo en Solid y observa que solo se actualiza el nodo del contador.
  2. Quita un useMemo de un componente React y razona qué trabajo extra aparece; busca el equivalente en Solid y comprueba que no existe porque el tracking ya es exacto.
  3. Abre el output compilado de un componente Solid y confirma que no hay ninguna llamada a nada parecido a createElement ni a un reconciliador.
  4. Argumenta, con el modelo de coste, por qué una app Solid con miles de nodos no se ralentiza al crecer la vista si los cambios siguen siendo locales.
  5. Compara mentalmente Svelte 5 con runes y Solid: ¿en qué convergen y en qué se separan respecto al momento en que se hace el trabajo?