Pull puro: calcular bajo demanda
El modelo opuesto: nadie recalcula nada hasta que alguien lee, y la validez se comprueba consultando hacia arriba. Perezoso y sin glitches por construcción, con dos costes que lo hacen inviable solo.
Invierte la dirección. En lugar de que la escritura empuje el cálculo, que la lectura lo tire. Un nodo no sabe cuándo cambian sus entradas; se entera al preguntar, y solo pregunta cuando alguien le pide su valor. El modelo es elegante, es perezoso por construcción y no puede producir glitches ni queriendo. Y tiene dos problemas que explican por qué ningún motor de interfaz lo usa solo.
- Implementar pull puro con verificación por versiones.
- Explicar por qué no puede producir glitches.
- Identificar el problema de los efectos y el de la recomputación redundante.
- Reconocer los sistemas reales que usan pull puro con éxito.
La implementación
En pull puro no hay lista de observadores: la fuente no necesita saber quién la lee. Lo que hace falta es una manera de que un nodo compruebe si sus entradas cambiaron desde la última vez que las miró, y la forma barata es un contador de versión por fuente.
let RelojGlobal = 0;
function fuente(inicial) {
const nodo = { valor: inicial, version: ++RelojGlobal };
const leer = () => { seguir(nodo); return nodo.valor; };
leer.set = (v) => {
if (Object.is(nodo.valor, v)) return;
nodo.valor = v;
nodo.version = ++RelojGlobal; // eso es todo lo que hace una escritura
};
return leer;
}
Una escritura cuesta una asignación y un incremento. No recorre nada, no notifica a nadie. Es la operación más barata posible.
El trabajo está en la lectura de una derivación: antes de devolver su caché, comprueba si alguna de sus fuentes tiene una versión más nueva que la que registró.
function derivada(fn) {
const nodo = { fn, valor: undefined, fuentes: [], versiones: [], calculado: false };
const leer = () => {
if (nodo.calculado && vigente(nodo)) { seguir(nodo); return nodo.valor; }
recalcular(nodo);
seguir(nodo);
return nodo.valor;
};
return leer;
}
function vigente(nodo) {
for (let i = 0; i < nodo.fuentes.length; i++) {
const f = nodo.fuentes[i];
if (f.fn) leer_(f); // si es derivada, resuelvela primero
if (f.version !== nodo.versiones[i]) return false;
}
return true;
}
vigente recorre las fuentes de arriba abajo, resolviendo recursivamente las derivaciones. La comprobación llega hasta las raíces del grafo antes de decidir.
Por qué no puede haber glitches
Esta es la propiedad más bonita del modelo, y es una consecuencia estructural, no una garantía añadida.
Un glitch es un valor calculado con entradas de épocas distintas. En pull puro eso es imposible porque la evaluación de un nodo empieza resolviendo sus dependencias. Cuando el cuerpo de c llama a b(), la llamada no devuelve hasta que b esté al día, y b a su vez no devolvió hasta que a estuvo al día. La recursión impone el orden topológico correcto sin que nadie lo calcule: el orden lo pone la pila de llamadas.
const a = senal(1);
const b = derivada(() => a() + 1);
const c = derivada(() => a() + b());
a.set(10);
c(); // evalua: lee a (10), lee b -> b comprueba su version, recalcula a 11, devuelve
// resultado 21, siempre correcto, no hay estado intermedio observable
El rombo que hacía fallar a push puro aquí no tiene ningún efecto. Y no porque el motor lo detecte, sino porque nunca se calcula nada antes que aquello de lo que depende. Es una garantía gratuita.
flowchart TB
L[alguien lee c] --> V{esta c vigente}
V -->|no| R1[resolver b primero]
R1 --> V2{esta b vigente}
V2 -->|no| R2[resolver a]
R2 --> C1[recalcular b]
C1 --> C2[recalcular c]
V -->|si| D[devolver la cache]
style L fill:#89b4fa,color:#11111b
style V fill:#f9e2af,color:#11111b
style V2 fill:#f9e2af,color:#11111b
style R1 fill:#cba6f7,color:#11111b
style R2 fill:#cba6f7,color:#11111b
style C1 fill:#a6e3a1,color:#11111b
style C2 fill:#a6e3a1,color:#11111b
style D fill:#94e2d5,color:#11111bProblema 1: los efectos no se disparan solos
Un efecto no lo lee nadie. Su valor está fuera del grafo: escribir en el DOM, mandar una petición, guardar en almacenamiento. En pull puro, un nodo solo se evalúa cuando alguien lo lee, así que un efecto no se ejecutaría jamás.
Las salidas posibles son tres, y ninguna es buena. Un bucle de sondeo que lea todos los efectos en cada fotograma: funciona, y hace trabajo constante aunque no cambie nada, además de acoplar la latencia a la frecuencia del bucle. Un sondeo con temporizador: peor todavía, porque introduce retraso. O añadir una notificación push solo para los efectos, que es admitir que el modelo puro no basta.
Este es el argumento decisivo. Un sistema reactivo sin efectos no sirve para nada, porque los efectos son el único punto donde el sistema toca el mundo. Cualquier motor de interfaz necesita push para al menos una cosa: enterarse de que hay que ejecutar un efecto.
Una hoja de cálculo es pull puro y funciona porque tiene un bucle de sondeo natural y barato: el repintado de las celdas visibles. Solo hace falta evaluar las celdas que se ven, y hay un momento obvio para hacerlo. Cuando existe un ciclo de renderizado que va a preguntar de todos modos, pull puro es viable. En una aplicación web con efectos arbitrarios —peticiones, temporizadores, almacenamiento— ese momento obvio no existe.
El segundo problema y dónde pull puro gana
Problema 2: recomputación redundante de la comprobación
El segundo problema es más sutil y afecta al rendimiento. Comprobar si un nodo está vigente cuesta recorrer sus fuentes; si son derivaciones, hay que resolverlas también. Cada lectura paga un recorrido hasta las raíces del grafo.
En una cadena de profundidad diez, leer la hoja implica diez comprobaciones aunque no haya cambiado nada. Y si la misma hoja se lee cien veces en un fotograma —cosa habitual en un bucle de render— son mil comprobaciones para descubrir que todo estaba al día.
La mitigación estándar es una marca de época: si un nodo ya se verificó en el ciclo actual, se salta la comprobación. Ayuda mucho, y a cambio introduce el concepto de ciclo, es decir, exactamente la maquinaria que pull puro pretendía evitar.
let Epoca = 0; // se incrementa una vez por ciclo de trabajo
function vigente(nodo) {
if (nodo.verificadoEn === Epoca) return true; // ya comprobado en este ciclo
nodo.verificadoEn = Epoca;
// ... recorrido normal
}
Compara la conclusión con la de la lección anterior y verás que son exactamente complementarias. Push responde bien a quién se ve afectado y mal a qué valor tiene esto ahora. Pull responde bien a qué valor tiene esto ahora —con orden correcto y sin trabajo especulativo— y no responde en absoluto a quién se ve afectado, porque su dirección no va de la causa al efecto. Cada modelo tiene la mitad de la respuesta, y la mitad que tiene la tiene de forma óptima. Esa complementariedad no es una casualidad afortunada: es que las dos preguntas necesitan recorrer el grafo en direcciones opuestas, y ninguna estructura recorre bien las dos direcciones a la vez. De aquí sale el diseño que usa todo el mundo, y sale por deducción y no por prueba y error: usa push para la dirección hacia adelante, pero empuja solo información barata —una marca— y no cálculo; usa pull para la dirección hacia atrás, pero solo sobre los nodos que la marca señaló. El resultado se llama push-pull y es la lección siguiente. Merece la pena apreciar que no es un compromiso entre dos opciones malas: es la combinación que toma de cada una precisamente aquello en lo que es óptima.
Dónde pull puro es la elección correcta
Igual que con push, hay dominios donde gana claramente.
Los sistemas de build son pull puro: make no hace nada hasta que le pides un objetivo, y entonces comprueba marcas de tiempo hacia arriba. Las marcas de tiempo son exactamente los contadores de versión de esta lección.
Los lenguajes perezosos lo son por definición: un valor no se evalúa hasta que se fuerza, y su evaluación fuerza a sus dependencias.
Las hojas de cálculo, como decíamos, por su ciclo de repintado natural.
En los tres casos se cumple lo mismo: existe un consumidor explícito que pide resultados y no hay efectos que tengan que dispararse solos. Cuando esas dos condiciones fallan, pull puro no basta.
- Implementa pull puro con versiones y una cadena de veinte derivaciones.
- Lee la hoja mil veces sin escribir nada y cuenta las comprobaciones de vigencia.
- Añade la marca de época y vuelve a medir.
- Explica qué garantía has tenido que introducir para que la marca de época sea correcta.