wandres.dev
SIGNALS · el modelo y TC39

El TC39 Signals proposal

Cada framework reimplementó el mismo primitivo; en 2026 ese primitivo se estandariza. El TC39 Signals proposal lleva Signal.State y Signal.Computed al propio lenguaje, con un Watcher de bajo nivel sobre el que los frameworks construyen sus efectos.

⏱ 15 min

Durante una década, Solid, Vue, Angular, Preact y Svelte resolvieron el mismo problema por separado, cada uno con su propia implementación de signals, incompatibles entre sí. En 2026 esa duplicación empieza a terminar: el TC39 Signals proposal —impulsado conjuntamente por autores de esos mismos frameworks— lleva el primitivo al corazón de JavaScript. No es otro framework más, sino el sustrato común sobre el que todos podrían apoyarse: Signal.State para el estado que se escribe, Signal.Computed para el que se deriva, y un Watcher de bajo nivel para construir efectos. Es el intento de convertir un patrón consolidado en parte del lenguaje.

🎯 Al terminar esta lección sabrás
  • Conocer Signal.State y Signal.Computed como primitivos del lenguaje.
  • Entender el Watcher como la raíz de bajo nivel de los efectos.
  • Ver por qué el proposal deja el scheduling en manos de los frameworks.
  • Situar la estandarización como interoperabilidad, no como una librería rival.
📦

Signal.State

El estado escribible. Se lee con .get() y se escribe con .set(); es la fuente que introduce datos nuevos en el grafo.

🧮

Signal.Computed

La derivación cacheada y perezosa. Se construye con una función y expone solo .get(); recalcula bajo demanda cuando sus fuentes cambian.

🛰️

Signal.subtle.Watcher

La raíz de los efectos. Notifica —una vez y de forma síncrona— cuando algo vigilado se ensucia, y deja el scheduling al framework.

Signal.State y Signal.Computed

El proposal expone dos clases. Signal.State envuelve un valor que se lee con .get() y se escribe con .set(); es el equivalente estándar del signal escribible que construimos a mano. Signal.Computed recibe una función de derivación y expone solo .get(); es el computed cacheado y perezoso, con el mismo rastreo automático y el mismo marcado sucio que ya conoces. La API es deliberadamente austera:

import { Signal } from "signal-polyfill";

const contador = new Signal.State(0);
const doble = new Signal.Computed(() => contador.get() * 2);

contador.get();     // lee y, si hay rastreo activo, registra la dependencia
doble.get();        // calcula 0 la primera vez y lo cachea

contador.set(5);    // escribe: marca sucio a doble, pero no lo recalcula
doble.get();        // recomputa perezosamente bajo demanda: 10

Lo notable no es que sea nuevo, sino que es el mismo modelo de las tres lecciones anteriores, ahora con nombres canónicos. .get() rastrea, .set() notifica, el computed cachea y difiere. Nada que aprender de cero; todo lo que ya sabes, con una firma estándar y única.

Ambas clases aceptan un segundo argumento de opciones, y la más útil es equals: una función de igualdad a medida que decide cuándo un valor cuenta como nuevo. Con ella gobiernas la propagación con precisión quirúrgica, por ejemplo tratando como iguales dos objetos de idéntico contenido para no notificar de más:

const punto = new Signal.State(
  { x: 0, y: 0 },
  { equals: (a, b) => a.x === b.x && a.y === b.y },  // igualdad estructural
);

punto.set({ x: 0, y: 0 });   // mismo contenido: equals lo corta, no notifica
punto.set({ x: 1, y: 0 });   // cambio real: notifica a los dependientes
ℹ️
El espacio Signal.subtle

Las operaciones avanzadas y potencialmente peligrosas viven bajo el espacio Signal.subtle, un nombre elegido a propósito para señalar cuidado —igual que crypto.subtle—. Allí están el Watcher, la lectura sin rastrear untrack y los ganchos de introspección. La idea es que el uso cotidiano se quede en Signal.State y Signal.Computed, mientras que quien construye un framework baja a Signal.subtle para cablear la reactividad con el ciclo de vida de su motor de render.

El Watcher: notificar, no ejecutar

Aquí está la decisión de diseño más importante del proposal: no incluye una función effect(). En su lugar ofrece un Watcher, un objeto de bajo nivel al que le entregas signals para vigilar y una función que se llama —de forma síncrona y una sola vez— cuando alguno de ellos se ensucia. El Watcher no recalcula ni re-ejecuta nada: solo avisa de que algo cambió. Qué hacer con ese aviso, y sobre todo cuándo, se lo deja al framework.

const w = new Signal.subtle.Watcher(() => {
  // se llama cuando algo vigilado se ensucia; NO calcules aqui
  queueMicrotask(() => {
    for (const s of w.getPending()) s.get();  // reevalua los computados sucios
    w.watch();                                 // se rearma para el proximo aviso
  });
});

w.watch(doble);     // empieza a vigilar el computed

La separación entre notificar y ejecutar es el meollo. El Watcher notifica de inmediato y sin coste; el framework decide el scheduling —agrupar cambios en un microtask, alinearlos con el frame de animación, priorizar lo visible—. Un efecto, en esta arquitectura, no es un primitivo: es una receta que combina un Watcher con una política de planificación.

flowchart TD
ST[Signal.State set] --> CO[Signal.Computed se marca sucio]
CO --> WA[el Watcher recibe el aviso]
WA --> SC[el framework agenda el trabajo]
SC --> EF[el efecto corre cuando el framework decide]
style ST fill:#89b4fa,color:#11111b
style WA fill:#fab387,color:#11111b
style EF fill:#a6e3a1,color:#11111b

Del Watcher al efecto

El Watcher es deliberadamente crudo, así que conviene ver cómo un framework lo convierte en el effect() que sí usas a diario. La receta combina tres piezas: un Signal.Computed que envuelve tu función para heredar su rastreo, un Watcher que avisa cuando ese computed se ensucia, y un planificador —aquí un simple queueMicrotask— que decide cuándo re-ejecutar.

// asi un framework construye su createEffect sobre el Watcher estandar
function createEffect(fn: () => void) {
  let watcher: Signal.subtle.Watcher;
  const comp = new Signal.Computed(() => { fn(); });
  const flush = () => {
    for (const s of watcher.getPending()) s.get();  // reevalua lo sucio
    watcher.watch();                                 // se rearma para el futuro
  };
  watcher = new Signal.subtle.Watcher(() => queueMicrotask(flush));
  watcher.watch(comp);
  comp.get();          // primera corrida: arranca el rastreo de dependencias
}

Cambia el queueMicrotask por el planificador de tu framework y tendrás su effect exacto: uno que agrupa por microtask, otro que se alinea con el frame de animación, otro que corre de inmediato. El proposal te entrega las piezas y tú ensamblas la política. Esa es, en una función de doce líneas, toda la relación entre el estándar y los frameworks que se apoyarán en él.

Por qué estandarizar

La pregunta natural es para qué. La respuesta es interoperabilidad. Hoy una librería de estado escrita para Vue no funciona en Solid, y un componente de Angular no comparte reactividad con uno de Preact, aunque los tres usen la misma idea. Si el primitivo vive en el lenguaje, una librería de lógica reactiva podría escribirse una vez y funcionar bajo cualquier framework que hable el estándar. El código de estado dejaría de estar casado con el framework de la vista.

Los beneficios concretos se acumulan rápido. Una librería de lógica de negocio —un carrito de compra, un editor de texto, una máquina de estado— podría publicarse una sola vez y consumirse desde Solid, Angular o Vue sin adaptadores. Las herramientas de depuración y las extensiones del navegador podrían inspeccionar el grafo reactivo de cualquier aplicación, hable el framework que hable, porque todas compartirían la misma representación. Y el estado podría probarse en tests puros, sin montar un framework entero solo para observar cómo un valor cambia y notifica. Cuando el primitivo pertenece al lenguaje, todo el ecosistema de herramientas que lo rodea se vuelve compartible de golpe.

💡
Un primitivo, no un framework

El proposal se detiene justo donde empieza la opinión. Da los signals y el watcher; no da efectos con scheduling, ni integración con el DOM, ni sintaxis de componentes. Esa contención es una virtud: estandarizar solo el núcleo común —sobre el que todos ya están de acuerdo— y dejar libre la capa donde los frameworks compiten y se diferencian. Es la misma filosofía que hizo de las Promises un primitivo del lenguaje mientras dejaba los patrones de orquestación a las librerías.

Estandarizar un patrón es el mayor cumplido que puede recibir

Que una idea nacida en librerías ascienda al propio lenguaje es un acontecimiento raro y revelador. Ocurrió con las Promises, que empezaron como bibliotecas rivales —Q, Bluebird, jQuery.Deferred, cada una con su dialecto— antes de que TC39 las unificara en un primitivo estándar y, sobre él, floreciera async/await. Los signals recorren hoy exactamente ese arco. El proposal es el reconocimiento de que la comunidad, tras una década de experimentación independiente, convergió en el mismo modelo esencial: un valor que rastrea a sus lectores, una derivación que cachea y difiere, y una notificación que separa el aviso de la ejecución. Que autores de frameworks históricamente rivales se sienten a estandarizar juntos este núcleo es la prueba más fuerte de que ya no es una apuesta de diseño, sino un hecho consolidado del campo. Y la arquitectura que eligieron encierra una lección de ingeniería que trasciende a los signals: estandariza el mecanismo, no la política. El proposal fija cómo se propaga un cambio —el grafo, el rastreo, el marcado sucio, la consistencia sin glitches— porque en eso hay una respuesta correcta compartida; pero se niega a fijar cuándo se ejecutan los efectos, porque eso es una decisión de producto donde el scheduling alineado con el frame de un motor de animación, el batching de un framework de UI y la ejecución inmediata de un script simple son todos legítimos, y ninguno debe imponerse a los demás. Esa disciplina —dar el primitivo compartido y detenerse antes de la capa opinada— es lo que permite que un estándar unifique sin uniformar, que quite la duplicación sin matar la diferenciación. Si dominas el modelo de las tres lecciones previas, no tienes nada nuevo que aprender aquí: el proposal es tu conocimiento con nombres oficiales. Pero su existencia cambia el horizonte, porque el día en que el navegador traiga signals nativos, la lógica de estado de tus aplicaciones podrá sobrevivir al framework que hoy la aloja. Aprendes el modelo una vez y lo llevas a todas partes; eso es, al final, lo que significa que algo se vuelva parte del lenguaje.

⚔️ Habla el estándar
  1. Instala un polyfill del proposal, crea un Signal.State y un Signal.Computed y confirma la pereza: cambia la fuente sin leer y comprueba que el computed no recalcula hasta que llamas a .get().
  2. Monta un Signal.subtle.Watcher que reprograme el trabajo en un queueMicrotask y observa cómo agrupa varios cambios en una sola pasada.
  3. Explica por qué el proposal ofrece un Watcher en lugar de un effect() ya hecho.
  4. Traza el paralelo histórico con las Promises: qué papel juega el Watcher frente al que jugó then.
  5. Argumenta, en tres líneas, qué gana la comunidad si la lógica reactiva deja de estar atada a cada framework.