Propagación glitch-free: el algoritmo push-pull
Los sistemas de señales de 2026 garantizan consistencia sin ordenar el grafo a mano: usan un algoritmo push-pull en dos fases. Al escribir, una fase de empuje solo marca de sucio el cono afectado; al leer, una fase de arrastre recalcula bajo demanda, validando primero las dependencias —de modo que el orden topológico emerge de la recursión— y cortando la propagación cuando un valor no cambia. Esta lección reconstruye ese algoritmo, lo contrasta con el pub/sub ingenuo y lo sitúa en el ecosistema que va de la propuesta de Signals de TC39 a Angular, Solid y Vue.
Hemos reunido las piezas: los glitches nacen del orden, el diamante es su forma mínima, el orden topológico los cura y el batching agrupa la propagación en el tiempo. Falta ensamblarlas en un algoritmo real, el que late bajo cada librería de señales seria de 2026. Ese algoritmo tiene un nombre —push-pull— y una idea rectora sorprendente: no calcula el orden topológico, deja que emerja. En lugar de ordenar el grafo por adelantado, invierte el control: al escribir solo marca, y al leer recalcula hacia atrás por las dependencias, de modo que el orden correcto aparece solo, como efecto del recorrido. Entender push-pull es entender por qué las señales modernas son perezosas, mínimas y glitch-free a la vez.
- Describir el pub/sub ingenuo y enumerar sus tres fallos: glitches, redundancia y falta de corte.
- Reconstruir el algoritmo push-pull en dos fases: marcar de sucio al escribir, recalcular al leer.
- Explicar cómo el orden topológico emerge del arrastre por dependencias sin ordenar nada a mano.
- Situar el corte por igualdad y el versionado como el estado del arte del ecosistema 2026.
El pub/sub ingenuo y sus tres pecados
El punto de partida que todos escribimos alguna vez: cada valor mantiene una lista de suscriptores; al cambiar, empuja su nuevo valor a cada uno, que recalcula al instante y empuja a su vez. Es empuje puro, ansioso y en profundidad. Ya sabemos que falla, y ahora podemos nombrar sus tres pecados con precisión.
Glitches
Al recorrer en profundidad, un nodo con dos rutas hacia una fuente se recalcula por la primera antes de que la segunda llegue: ve una entrada nueva y otra vieja. El diamante siempre lo delata.
Redundancia
Cada notificación recalcula. El vértice de un diamante corre tantas veces como aristas de entrada tiene, y el coste se dispara con reconvergencias apiladas.
Sin corte
Si un derivado recalcula al mismo valor de antes, el empuje ingenuo propaga igual: no compara, así que despierta a todo el subárbol aunque nada cambiara de verdad.
Hay un cuarto defecto, más silencioso: el empuje ansioso calcula valores que quizá nadie lee. Un derivado caro se recomputa en cada cambio de sus fuentes aunque ninguna vista lo muestre. El pub/sub ingenuo no tiene forma de ser perezoso.
Push-pull: marcar al escribir, recalcular al leer
Las señales modernas parten el trabajo en dos fases separadas en el tiempo. La primera, el empuje, ocurre al escribir y es baratísima: no calcula nada, solo pinta de sucio el cono de nodos afectados. La segunda, el arrastre, ocurre al leer: un nodo sucio se recalcula bajo demanda, y para ello valida primero sus dependencias.
const LIMPIO = 0, REVISAR = 1, SUCIO = 2
// FASE PUSH: al escribir solo se marca, no se calcula
function escribir(senal, valor) {
senal.valor = valor
senal.version++
for (const dep of senal.dependientes) marcar(dep, SUCIO)
}
function marcar(nodo, estado) {
if (nodo.estado >= estado) return // ya marcado igual o peor: corta
nodo.estado = estado
for (const dep of nodo.dependientes) marcar(dep, REVISAR) // transitivos: quizas
}
// FASE PULL: al leer se validan las dependencias primero
function leer(nodo) {
if (nodo.estado === REVISAR) {
for (const fuente of nodo.fuentes) {
leer(fuente) // arrastre recursivo
if (fuente.version !== nodo.versionVista.get(fuente)) nodo.estado = SUCIO
}
}
if (nodo.estado === SUCIO) {
const nuevo = nodo.calcular() // sus fuentes ya son finales: sin glitch
nodo.cambio = nuevo !== nodo.valor // corte por igualdad
nodo.valor = nuevo
}
nodo.estado = LIMPIO
return nodo.valor
}
La belleza está en la fase de arrastre: leer invoca leer sobre cada dependencia ANTES de calcular el nodo. Esa recursión desciende hasta las fuentes y regresa calculando de abajo arriba, así que cuando un nodo por fin se evalúa, todas sus dependencias ya tienen su valor final. Es exactamente el orden topológico de la lección 3, pero nadie lo calculó: emerge de la pila de llamadas del arrastre.
Recorramos el diamante precio, subtotal, iva, total con este algoritmo:
- Se escribe
precio. La fase de empuje marcasubtotaleivacomoSUCIOy, transitivamente,totalcomoREVISAR. No se calcula nada aún. - Un efecto lee
total. Como está enREVISAR, valida sus fuentes antes de calcularse a sí mismo. - Al validar, arrastra
subtotal: estáSUCIO, así que lo recalcula tras arrastrarprecio, que ya es final. - Arrastra
iva: estáSUCIO, lo recalcula con el mismoprecioya memoizado;preciono se evalúa dos veces. - Ambas fuentes cambiaron de versión, así que
totalpasa aSUCIOy se calcula UNA vez, consubtotaleivafinales.
precio se evaluó una vez, total una vez, y en ningún punto se combinó una rama nueva con otra vieja. El diamante que glitcheaba y se recalculaba de más queda resuelto por el mismo mecanismo, sin una sola línea de ordenación explícita.
stateDiagram-v2 [*] --> limpio limpio --> sucio: una dependencia directa cambio limpio --> revisar: una dependencia transitiva quizas cambio revisar --> limpio: al leer ninguna fuente cambio de valor revisar --> sucio: al leer una fuente si cambio de valor sucio --> limpio: al leer recalcula y memoiza
Versionado y corte por igualdad
Dos mecanismos elevan el algoritmo de correcto a eficiente. El primero es el corte por igualdad: si un nodo se recalcula al mismo valor que tenía, marca su cambio como falso y sus dependientes, al validarse, ven que su versión no se movió y se quedan limpios sin recalcular. Un cambio que no cambia nada muere en el sitio, en vez de despertar medio grafo.
El segundo es el versionado, que hace la pregunta “¿algo cambió?” en tiempo constante. Cada señal lleva un contador de versión que sube al escribir; cada derivado recuerda qué versión leyó de cada fuente. Un reloj global permite un atajo: si el reloj no se ha movido desde la última validación del nodo, está garantizadamente limpio y se devuelve su valor memoizado sin tocar sus fuentes. Solo cuando el reloj avanzó se recorre la lista de fuentes comparando versiones. Este esquema de versiones con dos marcas —SUCIO para “una dependencia directa cambió seguro” y REVISAR para “una transitiva quizás”— es el corazón del algoritmo que popularizó Reactively y que estandariza hoy la propuesta de Signals.
La marca REVISAR existe para no recalcular subárboles en balde. Si solo tuviéramos SUCIO, marcar todo el cono de un cambio obligaría a recalcular cada nodo del cono al leerlo. Con REVISAR, un nodo transitivo primero pregunta a sus fuentes si de verdad cambiaron de valor; si el corte por igualdad detuvo el cambio más abajo, el nodo vuelve a LIMPIO sin ejecutar su cómputo. La combinación de la marca ligera y el corte por igualdad es lo que hace que el trabajo sea proporcional a lo que realmente cambió de valor, no a lo que fue tocado.
El contraste con el pub/sub ingenuo se ve mejor lado a lado.
| Aspecto | Pub/sub ingenuo | Señales push-pull |
|---|---|---|
| Evaluación | ansiosa, empuja y recalcula | perezosa, recalcula al leer |
| Orden | en profundidad | dependencias primero |
| El diamante | de más y con glitch |
una vez y consistente |
| Valor sin cambio | vuelve a propagar | corta la propagación |
| Trabajo | proporcional al grafo | proporcional a lo leído |
| Consistencia | eventual | siempre glitch-free |
El ecosistema 2026
Casi toda la reactividad seria de hoy es una variación de este algoritmo. La propuesta de Signals de TC39 —con Signal.State, Signal.Computed y el Watcher de Signal.subtle— estandariza el push-pull perezoso con versionado, y su polyfill es prácticamente el pseudocódigo de arriba. Angular construyó sus señales sobre el mismo esquema; Preact Signals, SolidJS, la reactividad de Vue con ref y computed, las runes de Svelte 5, las derivaciones de MobX y las señales de Qwik comparten la misma médula: marcar al escribir, arrastrar al leer, cortar por igualdad.
Y hay un regalo extra: la pereza. Como nada se calcula hasta que se lee, un derivado caro que ninguna vista muestra jamás se evalúa, por mucho que sus fuentes cambien. El pub/sub ansioso no puede ofrecer esto —recomputa todo el cono en cada cambio—; el push-pull solo paga por lo que de verdad se observa.
Como los derivados son perezosos, un grafo push-pull en reposo no hace nada aunque sus fuentes cambien: solo acumula marcas. Quien lo pone en marcha es un efecto, la única pieza que DEBE ejecutarse. En la propuesta de Signals, un Watcher recibe la notificación de empuje, se programa —con el batching de la lección 4— y, al vaciarse, lee sus señales; esa lectura es el arrastre que lleva al grafo entero a consistencia. Sin un observador que lea, un computed puede quedarse sucio para siempre, y no pasa nada: nadie lo necesitaba.
El pub/sub ingenuo no desaparece: sigue siendo la herramienta correcta para eventos gruesos donde no hay invariantes que preservar —un bus de mensajes, los eventos del DOM, un emisor entre módulos—. Lo que este nivel enseña es cuándo NO basta: en cuanto tienes un grafo de derivaciones con invariantes, el pub/sub ingenuo te dará glitches, y necesitas push-pull.
Queda una sutileza que hace todo esto aún más notable: las dependencias no se declaran, se descubren. Cada vez que un computed se ejecuta, el motor registra qué señales leyó durante esa ejecución, y esas se vuelven sus fuentes; las que dejó de leer se desconectan. El grafo se re-teje en cada recálculo, y aun así el arrastre lo recorre en orden correcto, porque el orden emerge de las lecturas reales, no de una estructura fija.
Este rastreo automático de dependencias es lo que hace que las signals se sientan mágicas: escribes a() + b() y el sistema deduce solo que el computed depende de a y de b, sin que lo declares. Si en una rama del cálculo dejas de leer b, la arista hacia b desaparece en el siguiente arrastre y b ya no despertará a ese nodo. Dependencias dinámicas y push-pull encajan de forma natural: como el recálculo es el momento en que se leen las fuentes, es también el momento perfecto para volver a trazar las aristas.
El giro más profundo del push-pull es que resuelve el problema central del nivel —recorrer el grafo en orden de dependencias— sin construir jamás ese orden de forma explícita. En lugar de preguntar “¿en qué secuencia actualizo?”, invierte el control: el consumidor que lee dispara el cómputo, y como cada nodo, antes de calcularse, arrastra a sus dependencias, la recursión del arrastre RECORRE el grafo en orden topológico por pura consecuencia de su estructura. El orden no se planifica, emerge; la pila de llamadas es el planificador. Sobre esa base, el versionado convierte la pregunta “¿cambió algo?” en una comparación de enteros en tiempo constante, y el corte por igualdad garantiza que un cambio que no altera valores muera donde nace en vez de sacudir el grafo entero. Cuando se juntan las cuatro ideas del nivel —la consistencia como propiedad de cada instante, el diamante como su reto mínimo, el orden topológico como su cura y el batching como su agrupamiento en el tiempo— colapsan en este único algoritmo de dos fases. Y ahí está la lección última: el pub/sub que todos escribiríamos a mano es la línea base ingenua que cada librería moderna de señales fue diseñada para superar. No aprendes push-pull para memorizar una API, sino para reconocer que la reactividad glitch-free es un resultado de ingeniería duramente ganado, no la opción por defecto, y para saber exigirlo cuando tu estado tiene verdades que no admiten mentiras intermedias.
- Implementa
escribir,marcaryleercon las marcasLIMPIO,REVISARySUCIOy verifica que el diamante calcula cada nodo una sola vez. - Añade el corte por igualdad y comprueba que un cambio que no altera el valor no recalcula a los dependientes.
- Instrumenta el arrastre para imprimir el orden en que se calculan los nodos y confirma que coincide con un orden topológico.
- Crea un
computedcaro que ninguna lectura observe y demuestra que, con push-pull, nunca llega a ejecutarse. - Compara tu implementación con la propuesta de Signals de TC39: localiza
Signal.State,Signal.Computedy elWatcher.