Push puro: empujar el cambio hacia adelante
El modelo más directo, implementado entero: al escribir, recalcular en cascada todos los descendientes. Su coste, sus dos fallos característicos y por qué ningún motor de producción lo usa tal cual.
La primera idea que se le ocurre a cualquiera es también un modelo legítimo y muy usado fuera de las interfaces: cuando un valor cambia, recalcula inmediatamente todo lo que depende de él, en cascada, hasta las hojas. Es simple, es predecible en el sentido de que todo está siempre al día, y tiene exactamente dos problemas que lo hacen inviable en un grafo grande. Los dos son instructivos.
- Implementar push puro completo en menos de treinta líneas.
- Cuantificar el trabajo inútil que produce en un grafo con hojas invisibles.
- Reproducir el glitch que genera en un rombo.
- Reconocer los sistemas donde push puro es la elección correcta.
La implementación
Push puro no necesita ni estados ni pereza. Solo la dirección hacia adelante y una cascada recursiva.
function fuente(inicial) {
const nodo = { valor: inicial, observadores: new Set() };
const leer = () => { seguir(nodo); return nodo.valor; };
leer.set = (v) => {
if (Object.is(nodo.valor, v)) return;
nodo.valor = v;
for (const o of [...nodo.observadores]) recalcular(o); // cascada inmediata
};
return leer;
}
function recalcular(nodo) {
desatar(nodo);
const anterior = Observador;
Observador = nodo;
try { nodo.valor = nodo.fn(); } finally { Observador = anterior; }
for (const o of [...nodo.observadores ?? []]) recalcular(o); // sigue empujando
}
Y ya está. No hay marcado, no hay colas, no hay estados. Cada escritura desencadena un recorrido en profundidad del subgrafo alcanzable, recalculando cada nodo al visitarlo. Todo está siempre al día porque nada se difiere.
Nota el [...nodo.observadores]: hay que copiar la colección antes de iterarla porque recalcular puede modificarla al desatar y volver a tejer aristas. Es el primer indicio de que el modelo es más delicado de lo que parece.
Problema 1: trabajo que nadie mira
En push puro, un nodo se recalcula porque su entrada cambió, con independencia de que alguien vaya a leer su resultado.
const consulta = senal('');
const filtrados = memo(() => catalogo().filter(p => p.nombre.includes(consulta())));
const agrupados = memo(() => agrupar(filtrados()));
const estadisticas = memo(() => calcularEstadisticas(agrupados()));
// La pestana de estadisticas esta cerrada. Nadie lee estadisticas.
Con push puro, cada tecla que el usuario pulsa recalcula los tres memos: el filtrado, la agrupación y las estadísticas. Los tres. Aunque el panel de estadísticas lleve una hora cerrado y su resultado no vaya a mirarse nunca.
El desperdicio crece con la profundidad y con la ramificación. En un grafo con cincuenta derivaciones colgando de una fuente que cambia en cada pulsación, se pagan cincuenta recálculos por tecla, de los cuales quizá dos importan.
La pereza requiere posponer el cálculo hasta que alguien lo pida, y push puro define el cálculo como consecuencia inmediata de la escritura. Son incompatibles: no existe un push puro perezoso, porque en el momento en que difieres el cálculo has introducido un estado intermedio de nodo pendiente y ya estás en el modelo híbrido. Esta incompatibilidad no es un detalle de implementación, es una propiedad del modelo.
Problema 2: los glitches
El segundo problema es de corrección, y es peor. Considera el rombo.
const a = senal(1);
const b = memo(() => a() + 1);
const c = memo(() => a() * 10);
efecto(() => console.log(b() + c()));
// estado inicial: b = 2, c = 10, el efecto imprime 12
Ahora a.set(2). Push puro recorre los observadores de a en orden y recalcula en profundidad desde cada uno.
Visita b: lo recalcula a 3, y como b tiene un observador —el efecto— empuja hacia él. El efecto se ejecuta e imprime 3 + 10 = 13. Ese valor no corresponde a ningún estado del sistema: no es el de a igual a 1, que era 12, ni el de a igual a 2, que será 23. Es un valor imposible, y ya se ha impreso.
Después vuelve y visita c: lo recalcula a 20, empuja al efecto, que se ejecuta otra vez e imprime 23. Correcto, pero tarde.
flowchart TB A[a pasa de 1 a 2] --> B[b se recalcula a 3] B --> E1[efecto imprime 13 valor imposible] A --> C[c se recalcula a 20] C --> E2[efecto imprime 23 valor correcto] style A fill:#89b4fa,color:#11111b style B fill:#cba6f7,color:#11111b style C fill:#cba6f7,color:#11111b style E1 fill:#f38ba8,color:#11111b style E2 fill:#a6e3a1,color:#11111b
Dos ejecuciones donde debería haber una, y una de ellas con un valor que viola la invariante del sistema. Si en vez de un console.log fuera una petición de red, se habría enviado una petición con datos incoherentes. Si fuera una escritura en el DOM, el usuario habría visto un parpadeo. El nivel 5 va entero sobre este fenómeno.
Dónde push puro es la elección correcta
Sería injusto presentarlo solo como un modelo defectuoso. Push puro es lo correcto cuando se cumplen tres condiciones, y hay dominios enteros donde se cumplen.
Cuando todas las salidas importan siempre. Si no hay hojas invisibles, el trabajo inútil desaparece por definición. En un procesador de audio con un grafo de nodos, todas las salidas se consumen en cada bloque de muestras.
Cuando la latencia manda sobre el rendimiento. Push tiene la latencia mínima posible: en el momento en que la escritura vuelve, todo está calculado. Pull tiene que resolver la cadena bajo demanda. En sistemas de control en tiempo real eso decide.
Cuando el grafo es plano. Sin profundidad no hay rombos, y sin rombos no hay glitches. Un sistema de eventos con manejadores de un solo nivel es push puro y funciona perfectamente.
Fuera de esas condiciones, y una interfaz de usuario está fuera de las tres, push puro es la elección incorrecta. Su lugar en este track es servir de mitad de la solución: la mitad que avisa. La lección siguiente estudia la otra mitad.
Aquí está la reformulación que hace que el nivel entero encaje. Push es la única forma de responder a la pregunta quién se ve afectado por este cambio, porque es la única dirección que va de la causa al efecto; ningún mecanismo de pull puede descubrir a los afectados sin preguntar a todo el mundo. Pero push es una forma mala de responder a la pregunta qué valor tiene este nodo ahora, porque obliga a calcular antes de saber si a alguien le importa y porque el orden de visita no respeta la topología. El error del modelo no está en empujar: está en empujar el cálculo en vez de empujar la noticia. Y en cuanto lo enuncias así, la solución se vuelve obvia y casi inevitable: empuja la noticia —marca los nodos afectados— y deja el cálculo para cuando alguien pregunte. Eso es exactamente el modelo híbrido, y todos los motores de producción de esta familia lo implementan, hasta el último. Que la solución se deduzca tan limpiamente de separar las dos preguntas es un buen ejemplo de por qué merece la pena estudiar los modelos fallidos: el híbrido no se entiende como una tercera opción arbitraria, sino como la consecuencia lógica de haber diagnosticado bien los dos anteriores.
- Implementa push puro con el código de esta lección.
- Construye el rombo y registra cada valor que el efecto observa, no solo el último.
- Cuenta cuántas ejecuciones del efecto hubo y cuántas produjeron valores que no corresponden a ningún estado.
- Añade un tercer camino desde la fuente y comprueba cómo crece el número de ejecuciones sobrantes.