wandres.dev
NIVEL DIOS · Síntesis de la reactividad fina

Parte 2: los tres estados, el memo y las dos fases

Sustituimos la cascada inmediata por marcado y resolución perezosa: tres estados, una cola mínima de efectos y el memo. Con esto desaparecen a la vez el trabajo inútil y los glitches.

⏱ 19 min

La parte 1 ejecutaba cuerpos durante la escritura, y de ahí venían sus dos defectos. Ahora separamos las dos cosas: la escritura solo marca, y la evaluación ocurre después, resolviendo hacia arriba. Con esa separación aparecen la pereza, el corte por igualdad y la consistencia glitch-free, las tres de golpe, porque las tres eran consecuencia del mismo cambio.

🎯 Al terminar esta lección sabrás
  • Implementar los tres estados y la función de marcado.
  • Escribir la resolución hacia arriba con el estado intermedio.
  • Implementar el memo perezoso con corte por igualdad.
  • Entender por qué el marcado obliga a introducir una cola.

Los tres estados y el marcado

const LIMPIO = 0, QUIZA = 1, SUCIO = 2;

function marcar(fuente, estado) {
  for (const obs of fuente.observadores) {
    if (obs.estado >= estado) continue;          // nunca degradar ni repetir
    const antes = obs.estado;
    obs.estado = estado;
    if (obs.efecto) planificar(obs);             // los efectos hay que ejecutarlos
    if (antes === LIMPIO) marcar(obs, QUIZA);    // los nietos, solo quiza
  }
}

Cuatro líneas de cuerpo y cada una es una decisión del nivel 4.

La guarda de no degradar impide que un nodo pase de SUCIO a QUIZA y, de paso, corta la propagación cuando ya estaba marcado, que es lo que evita recorrer el mismo subgrafo una vez por camino en un rombo.

La llamada recursiva pasa QUIZA y no estado: la certeza no se hereda, porque entre mi padre y yo hay un cálculo que puede devolver lo mismo.

Y solo se propaga if (antes === LIMPIO): si el nodo ya estaba en QUIZA, sus descendientes también lo están y volver a recorrerlos no añadiría información. Es lo que mantiene el marcado en coste lineal.

Con marcar escrito, el setter de la parte 1 cambia: deja de ejecutar cuerpos y pasa a marcar. Es la única línea del setter que se toca, y es el cambio que define esta parte entera.

function senal(inicial, opciones = {}) {
  const nodo = { valor: inicial, observadores: new Set(), iguales: opciones.iguales ?? Object.is };
  const leer = () => { seguir(nodo); return nodo.valor; };
  leer.set = (sig) => {
    const v = typeof sig === 'function' ? sig(nodo.valor) : sig;
    if (nodo.iguales(nodo.valor, v)) return v;
    nodo.valor = v;
    lote(() => marcar(nodo, SUCIO));    // antes: for (...) correr(o)
    return v;
  };
  return leer;
}

La función correr de la parte 1 desaparece: su trabajo lo absorbe actualizar, que además sabe resolver el estado antes de decidir si hay que ejecutar algo.

La cola, que aparece aquí por necesidad

Fíjate en planificar. No ejecuta el efecto: lo encola. Y tiene que ser así, porque ejecutar un cuerpo durante el marcado leería nodos aún sin marcar y reintroduciría los glitches. La cola no es una optimización de agrupamiento: es parte de la corrección, como argumentaba el nivel 5.

let Lote = null;

function lote(fn) {
  if (Lote) return fn();                         // reentrante: nos sumamos al abierto
  const cola = Lote = new Set();
  try { return fn(); }
  finally {
    for (const e of cola) {                      // recoge tambien lo anadido al vaciar
      cola.delete(e);
      if (e.estado !== LIMPIO) actualizar(e);
    }
    Lote = null;
  }
}

function planificar(efecto) {
  if (Lote) Lote.add(efecto);
  else lote(() => Lote.add(efecto));             // una escritura suelta abre su lote
}

Que Lote siga siendo no nulo durante el vaciado es deliberado: si un efecto escribe mientras se vacía, su propagación se suma a esta misma cola en vez de abrir una anidada, y el bucle la recoge. Sin eso, un efecto que escribe provocaría una ejecución reentrante en medio del vaciado, con resultados impredecibles.

La parte 4 amplía esta función y la expone como API pública; por ahora es maquinaria interna.

La resolución hacia arriba

Aquí está el corazón del motor. Un nodo QUIZA pregunta a sus fuentes antes de decidir.

function actualizar(nodo) {
  if (nodo.estado === QUIZA) {
    for (const f of nodo.fuentes) {
      if (f.fn) actualizar(f);                   // solo las computaciones se resuelven
      if (nodo.estado === SUCIO) break;          // una de ellas cambio de verdad
    }
    if (nodo.estado === QUIZA) {                 // ninguna cambio
      nodo.estado = LIMPIO;
      return;                                     // ni se ejecuta el cuerpo
    }
  }
  if (nodo.estado !== SUCIO) return;

  desatar(nodo);
  const oA = Observador;
  Observador = nodo;
  try {
    const v = nodo.fn(nodo.valor);
    if (nodo.efecto) { nodo.valor = v; return; }
    if (!nodo.iguales(nodo.valor, v)) {          // el valor cambio: la cascada sigue
      nodo.valor = v;
      for (const o of nodo.observadores) {
        if (o.estado >= SUCIO) continue;
        o.estado = SUCIO;                         // ascenso de quiza a sucio
        if (o.efecto) planificar(o);
      }
    }
  } finally {
    Observador = oA;
    nodo.estado = LIMPIO;
  }
}

El if (f.fn) distingue una computación de una señal: las señales no tienen cuerpo y no hay nada que resolver en ellas.

El ascenso al final solo toca a los observadores directos. Los indirectos ya están en QUIZA desde el marcado y descubrirán la verdad cuando les toque preguntar. Esa economía es lo que hace que la propagación no recorra el grafo dos veces.

Los primitivos y las pruebas

El memo y el efecto

Con actualizar escrito, los dos primitivos son envoltorios de tres líneas.

function crearComputacion(fn, esEfecto, iguales) {
  return {
    fn, valor: undefined, estado: SUCIO, efecto: esEfecto,
    fuentes: new Set(), observadores: new Set(),
    iguales: iguales ?? Object.is,
  };
}

function memo(fn, opciones = {}) {
  const nodo = crearComputacion(fn, false, opciones.iguales);
  return () => {
    if (nodo.estado !== LIMPIO) actualizar(nodo);   // pull: resuelvo si hace falta
    seguir(nodo);                                    // me registro con quien me lee
    return nodo.valor;
  };
}

function efecto(fn) {
  const nodo = crearComputacion(fn, true);
  actualizar(nodo);                                  // ansioso: corre al crearse
  return nodo;
}

El memo nace SUCIO y no se evalúa hasta que alguien lo lee. El orden dentro del getter importa: actualizar cambia el observador actual mientras ejecuta, y seguir tiene que usar el externo.

flowchart TB
W[escritura] --> M[marcar hacia adelante]
M --> M1[observadores directos a SUCIO]
M --> M2[descendientes a QUIZA]
M --> Q[efectos marcados a la cola]
Q --> V[vaciar la cola]
V --> A[actualizar cada efecto]
A --> R[resolver fuentes hacia arriba]
R --> D{alguna cambio}
D -->|si| E[ejecutar el cuerpo]
D -->|no| L[pasar a LIMPIO sin ejecutar]
style W fill:#89b4fa,color:#11111b
style M fill:#f9e2af,color:#11111b
style M1 fill:#f38ba8,color:#11111b
style M2 fill:#f9e2af,color:#11111b
style Q fill:#fab387,color:#11111b
style V fill:#cba6f7,color:#11111b
style A fill:#cba6f7,color:#11111b
style R fill:#cba6f7,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b
style L fill:#a6e3a1,color:#11111b

Pruébalo

Los cinco casos que ahora pasan y antes no.

// 1. El rombo, sin valores imposibles ni ejecuciones de mas
const a = senal(1);
const b = memo(() => a() + 1);
const c = memo(() => a() * 10);
const log = [];
efecto(() => log.push(b() + c()));
a.set(2);
console.assert(JSON.stringify(log) === '[12,23]');

// 2. El memo corta la cascada cuando su valor no cambia
const n = senal(1);
let veces = 0;
const par = memo(() => n() % 2 === 0);
efecto(() => { par(); veces++; });
n.set(3); n.set(5);
console.assert(veces === 1);

// 3. Pereza: el cuerpo no corre hasta que alguien lee
const d = senal(1);
let calculos = 0;
const m = memo(() => { calculos++; return d() * 2; });
console.assert(calculos === 0);
m(); d.set(2);
console.assert(calculos === 1);          // escribir no recalcula
console.assert(m() === 4 && calculos === 2);

// 4. QUIZA corta una cadena entera
const s = senal(1);
let c1 = 0, c2 = 0, e = 0;
const m1 = memo(() => { c1++; return s() > 0; });
const m2 = memo(() => { c2++; return m1() ? 'si' : 'no'; });
efecto(() => { m2(); e++; });
s.set(2); s.set(3);
console.assert(c1 === 3 && c2 === 1 && e === 1);

El cuarto caso es el que más dice: dos escrituras recorren el grafo, ejecutan solo el primer memo, y como su booleano no cambia, ni el segundo memo ni el efecto se ejecutan. Un cuerpo de tres posibles, dos veces seguidas.

Tres defectos arreglados por un solo cambio, y no es casualidad

Merece la pena apreciar lo que acaba de pasar, porque es la clase de resultado que indica que un diseño está bien encontrado. Hemos hecho un solo cambio conceptual —dejar de ejecutar durante la escritura y limitarnos a marcar— y con él han desaparecido tres problemas que parecían independientes. Los glitches, porque ya no hay evaluación durante el marcado. El trabajo inútil de los nodos que nadie lee, porque la evaluación se dispara desde la lectura. Y el trabajo inútil de las cascadas cuyo valor no cambia, porque el estado intermedio permite que un nodo se declare limpio sin ejecutarse. Cuando un cambio de diseño resuelve tres síntomas a la vez, casi siempre significa que los tres eran manifestaciones de la misma causa, y aquí lo eran: la escritura estaba tomando decisiones para las que no tenía información. Al posponerlas hasta el momento de la lectura, el motor decide con todo el contexto disponible. Es el mismo principio que la evaluación perezosa, la resolución tardía o la asignación diferida de recursos, y da el mismo resultado. Guarda esta observación como criterio de diseño para tus propios sistemas: cuando estés arreglando tres bugs con tres parches distintos, busca la decisión prematura que los une, porque casi siempre existe.

⚔️ Verifica las dos fases
  1. Escribe la parte 2 completa y ejecuta los cuatro casos de prueba.
  2. Cambia planificar para que ejecute en vez de encolar y comprueba que el rombo vuelve a producir el valor imposible.
  3. Elimina el estado QUIZA dejando solo limpio y sucio, y cuenta los cuerpos ejecutados en el cuarto caso.
  4. Quita la guarda de no degradar y busca un grafo donde eso produzca recorridos repetidos.