wandres.dev
LOS MOTORES COMPARADOS · Solid, Vue, Angular, Svelte, Preact

Svelte: runes y la reactividad que compila

Las decisiones de Svelte 5: runes como marcadores para el compilador, proxies para objetos, agrupamiento por tick, y la eliminación de la sintaxis de acceso a costa de una diferencia entre lo escrito y lo ejecutado.

⏱ 17 min

Svelte 5 hace algo que ningún otro motor de la familia: elimina la sintaxis de acceso reactivo por completo. No hay paréntesis, no hay punto value, no hay getters visibles. Lo consigue haciendo que el compilador reescriba el código, y las runes son las marcas que le dicen dónde. Es la apuesta más agresiva por la ergonomía de todo el grupo, con las contrapartidas que eso implica.

🎯 Al terminar esta lección sabrás
  • Situar las decisiones de Svelte en las ocho dimensiones del track.
  • Entender qué es una rune y por qué no es una función normal.
  • Reconocer qué genera el compilador a partir de cada rune.
  • Identificar las fronteras donde la magia del compilador se acaba.

Las ocho decisiones

Representación del grafo. Nodos con listas de reacciones y dependencias, y banderas de estado, en el runtime interno del cliente. Estructuras equivalentes a las de los demás.

Descubrimiento. Tracking automático en tiempo de ejecución. El compilador no deduce las dependencias: inserta las llamadas para que el runtime las descubra.

Propagación. Push-pull con marcado y verificación perezosa.

Consistencia. Glitch-free, con agrupamiento dentro del mismo tick.

Memoización. $derived para expresiones y $derived.by para cuerpos con bloque.

Ciclo de vida. Un $effect se destruye con su componente. $effect.root crea un ámbito desacoplado y devuelve una función de desecho, que es la raíz del nivel 7.

Planificación. Agrupamiento por tick, con una utilidad para esperar al siguiente vaciado. $effect.pre para el trabajo anterior a la actualización del DOM.

Modelo de estado. $state unifica los dos: si el valor es un objeto o un array, lo envuelve en un proxy; si es un primitivo, es atómico. $state.raw desactiva la envoltura y $state.snapshot obtiene una copia plana.

Qué es una rune

Una rune no es una función. Es un símbolo que el compilador reconoce y que no existe en tiempo de ejecución: no se importa, no se puede pasar como valor, no se puede almacenar en una variable.

let contador = $state(0);
let doble = $derived(contador * 2);

$effect(() => { console.log(doble); });

contador++;      // esto propaga

Fíjate en la última línea: un incremento normal de una variable normal. En el resto de motores eso sería una llamada a un setter. El compilador transforma la declaración en una fuente y el incremento en una escritura.

Y fíjate en $derived(contador * 2): no recibe una función sino una expresión. Solo un compilador puede hacer eso, porque necesita capturar la expresión sin evaluarla y convertirla en un cuerpo reejecutable.

⚠️
Las runes solo funcionan donde el compilador llega

Como no son funciones, no se pueden usar en un fichero que el compilador no procese, ni dentro de una función que se pase a una librería que las evalúe en otro contexto. La regla es que las runes viven en los ficheros que el compilador de Svelte transforma. Fuera de ahí, la reactividad hay que construirla con las APIs de ejecución que el paquete expone.

Qué genera el compilador

Aproximadamente, la transformación convierte cada rune en llamadas a las funciones internas del runtime. El detalle exacto cambia entre versiones y no conviene depender de él, pero la forma es reconocible: una declaración de estado se convierte en la creación de una fuente, cada lectura de esa variable en una llamada de lectura, y cada escritura en una llamada de escritura.

// Lo que escribes
let n = $state(0);
let doble = $derived(n * 2);
n++;

// La forma de lo que se ejecuta, esquematizada
const n = crearFuente(0);
const doble = crearDerivada(() => leer(n) * 2);
escribir(n, leer(n) + 1);

La consecuencia es que el código que depuras no es el que escribiste. Con los mapas de fuentes la experiencia es razonable, pero cuando algo falla en el runtime la traza señala funciones internas que no aparecen en tu fichero. Es el coste de depurabilidad del nivel 11, y aquí es donde más se nota de toda la familia.

flowchart TB
E[codigo con runes] --> C[compilador de Svelte]
C --> R[llamadas al runtime]
R --> G[el mismo grafo de siempre]
E -.lo que depuras.-> R
style E fill:#89b4fa,color:#11111b
style C fill:#fab387,color:#11111b
style R fill:#cba6f7,color:#11111b
style G fill:#a6e3a1,color:#11111b

Las fronteras y el balance

Las fronteras de la magia

Tres sitios donde el modelo se vuelve visible otra vez, y conviene conocerlos porque son donde aparecen las sorpresas.

Pasar estado a una función. Si pasas contador a una función, pasas su valor actual, no la reactividad. Para conservarla hay que pasar un getter, exactamente igual que en cualquier otro motor. La ausencia de sintaxis de acceso hace esta frontera menos visible, no menos real.

// no reactivo dentro de la funcion
usar(contador);
// reactivo
usar(() => contador);

Los límites de módulo. Una variable de estado declarada en un fichero y exportada no lleva su reactividad al importarla como valor. El patrón habitual es exportar una función o un objeto con getters.

Las clases nativas. Como en cualquier motor con proxies, Map, Set, Date y compañía no son observables sin instrumentación. Svelte publica versiones reactivas en svelte/reactivity: SvelteMap, SvelteSet, SvelteDate, SvelteURL.

Lo que gana y lo que pierde

Gana la ergonomía más limpia de la familia. Escribes JavaScript normal con variables normales. Un incremento es un incremento. Para código de aplicación cotidiano, la diferencia con los demás es notable y es real.

Gana un runtime pequeño, porque parte del trabajo está en el código generado y porque el compilador puede omitir la maquinaria que un componente concreto no necesita.

Pierde transparencia. Cuando algo no reacciona, la causa está en una transformación que no ves. Y como no hay sintaxis que distinga una variable reactiva de una normal, hay que recordar cuál era cuál.

Pierde portabilidad. Las runes no funcionan fuera del compilador, así que la lógica reactiva no se puede compartir con código que no sea de Svelte sin traducirla.

Svelte apuesta a que la sintaxis de acceso es el coste principal, y en gran medida acierta

La tesis de diseño de Svelte 5 se puede enunciar con precisión: lo que hace incómoda la reactividad de grano fino no es su modelo mental sino su sintaxis de acceso. Los paréntesis de Solid, el punto value de Vue, las llamadas de Angular; todo eso es ruido que un compilador puede eliminar sin cambiar la semántica. Y hay bastante evidencia de que la tesis es correcta: quien viene de otro framework se adapta a las runes muy rápido, y la queja habitual sobre las señales —que hay que acordarse de llamarlas— desaparece. Ahora bien, la tesis tiene un límite que conviene ver con claridad, y es el mismo que aparece en el nivel 9 con los proxies: al eliminar la sintaxis de acceso también eliminas la señal visual de que hay una suscripción ahí. En Solid, usuario() grita que estás leyendo algo reactivo y que el sitio donde lo lees importa. En Svelte, usuario no grita nada, y la diferencia entre leerlo dentro de un efecto y fuera de él —que sigue siendo total— es invisible en el código. El compilador no puede eliminar esa distinción porque es semántica, no sintáctica; solo puede ocultarla. Así que la apuesta se resume en aceptar más facilidad para escribir a cambio de más dificultad para razonar sobre el código escrito, y cuál de las dos pesa más depende de si tu problema dominante es producir código o mantenerlo.

⚔️ Mira lo que genera el compilador
  1. Escribe un componente mínimo con $state, $derived y $effect.
  2. Compílalo y examina la salida, localizando las llamadas al runtime que corresponden a cada rune.
  3. Pasa una variable de estado a una función y comprueba que la reactividad se pierde.
  4. Cámbialo por un getter y verifica que se conserva, explicando por qué en términos del nivel 3.