wandres.dev
EL GRAFO REACTIVO · Nodos, aristas y dependencias

Fuentes, computaciones y observadores

Los dos tipos de nodo del grafo reactivo, la razón por la que un memo es los dos a la vez, y los campos exactos que necesita cada estructura para que el algoritmo de propagación funcione.

⏱ 17 min

Abrimos el grafo. Debajo de cualquier motor de grano fino hay dos formas de objeto y solo dos, con un tercer caso que resulta ser la unión de las dos. Esta lección define esas estructuras campo a campo, justificando cada campo por el algoritmo que lo necesita, porque un campo que no se puede justificar por un algoritmo concreto es un campo que sobra.

🎯 Al terminar esta lección sabrás
  • Definir la estructura de una fuente y justificar cada uno de sus campos.
  • Definir la estructura de una computación y distinguir sus dos sabores.
  • Explicar por qué una derivación hereda las dos formas simultáneamente.
  • Determinar qué campos son obligatorios y cuáles son optimizaciones.

La fuente

Una fuente es un nodo sin entradas: su valor solo cambia porque algo externo lo escribe. Es la raíz del grafo y la única puerta por la que entra información nueva.

const fuente = {
  valor: undefined,          // el dato
  observadores: new Set(),   // quien depende de mi
  iguales: Object.is,        // que cuenta como cambio
};

Tres campos, y cada uno responde a una pregunta del algoritmo.

valor existe porque una fuente tiene que poder leerse en cualquier momento sin recalcular nada. Esa es justamente la diferencia con un stream: un stream emite y se olvida; una fuente retiene.

observadores existe porque la propagación necesita saber a quién notificar. Es la única dirección que se usa al escribir, y por eso es la primera que aparece en cualquier implementación ingenua.

iguales existe porque la mayor parte del trabajo excedente de un sistema reactivo viene de propagar cambios que no cambian nada. Poner Object.is por defecto es la decisión que toman casi todos los motores, y significa que escribir un objeto nuevo con el mismo contenido propaga. Vive con ello o pasa una función propia.

📝
Object.is y no la doble igualdad

Object.is se elige sobre === por dos casos concretos. Trata NaN como igual a sí mismo, evitando que escribir NaN sobre NaN propague eternamente; y distingue 0 de -0, cosa que === no hace. Son casos raros, pero un motor que se los come produce bucles infinitos difíciles de diagnosticar, y por eso Solid, Vue, Angular y la propuesta de TC39 usan todos la misma semántica por defecto.

La computación

Una computación es un nodo con un cuerpo que reejecutar y una memoria de qué leyó la última vez.

const computacion = {
  fn,                        // el cuerpo
  fuentes: new Set(),        // de quien leo, descubierto al ejecutar
  estado: SUCIO,             // limpio, quiza sucio, sucio
  valor: undefined,          // lo ultimo que devolvio
};

fn es evidente. Los otros tres no lo son tanto.

fuentes es la dirección inversa de las aristas, y no la necesita la propagación sino la limpieza: antes de reejecutar hay que borrarse de las listas de observadores de las fuentes viejas, y para eso hay que saber cuáles eran. Sin este campo, una dependencia que deja de leerse se queda para siempre y el nodo se reejecuta por cambios que ya no le afectan. La lección 2 de este nivel va entera sobre esta arista.

estado es la máquina de tres estados que hace posible el modelo híbrido push-pull del nivel 4. Con dos estados —limpio y sucio— no se puede expresar la situación intermedia de un nodo que quizá deba recalcularse, y sin esa situación intermedia el motor recalcula de más o produce glitches.

valor en una computación sirve para dos cosas distintas según el sabor del nodo, y de ahí sale la subdivisión.

Los dos sabores de computación

Una derivación produce un valor que otros nodos leen. Su valor es la caché de su última evaluación, y es lo que devuelve cuando alguien la lee y está limpia. Es el createMemo de Solid, el computed de Vue y de Angular, el computed de Preact Signals, el Signal.Computed de TC39.

Un efecto no produce valor útil: existe por lo que hace fuera del sistema. Su valor guarda, como mucho, lo que devolvió la ejecución anterior para pasárselo a la siguiente, que es un patrón útil pero secundario. Es el createEffect de Solid, el watchEffect de Vue, el effect de Angular y de Preact Signals.

La distinción no es cosmética: decide cuándo se ejecuta el nodo. Una derivación es perezosa y se evalúa cuando alguien la lee. Un efecto es ansioso y hay que ejecutarlo aunque nadie lo mire, porque su valor está fuera del grafo. Todos los motores llevan un campo booleano para separarlos: pure en Solid, y equivalentes en los demás.

const esDerivacion = { fn, fuentes: new Set(), observadores: new Set(), estado: SUCIO, valor: undefined };
const esEfecto     = { fn, fuentes: new Set(), estado: SUCIO, efecto: true };

La derivación y los campos que sobran

La derivación es fuente y computación a la vez

Mira los dos objetos anteriores. La derivación tiene observadores, que era un campo de la fuente, y fuentes, que era un campo de la computación. No es una casualidad de la implementación: es la definición del nodo.

Una derivación se lee como una fuente —devuelve un valor, registra al lector— y se reejecuta como una computación —tiene cuerpo, tiene dependencias, se ensucia. Por eso ocupa el centro del grafo, y por eso es el único nodo con las dos direcciones pobladas.

flowchart LR
F1[fuente a] --> D[derivacion total]
F2[fuente b] --> D
D --> E1[efecto escribe en el DOM]
D --> E2[efecto guarda en local storage]
style F1 fill:#89b4fa,color:#11111b
style F2 fill:#89b4fa,color:#11111b
style D fill:#cba6f7,color:#11111b
style E1 fill:#a6e3a1,color:#11111b
style E2 fill:#a6e3a1,color:#11111b

Las fuentes son raíces y no tienen fuentes. Los efectos son hojas y no tienen observadores. Las derivaciones son el interior y tienen ambos. Esa clasificación se cumple en todos los motores de la familia, y reconocerla al leer un código fuente ajeno ahorra mucho tiempo.

La estructura de datos ya contiene el algoritmo

Aquí hay una lección de diseño que trasciende la reactividad. Si te dan solo estas dos estructuras, sin ver una línea del algoritmo, puedes deducir el algoritmo completo. Que la fuente tenga observadores te dice que existe una propagación hacia adelante. Que la computación tenga fuentes te dice que existe una operación de desatado, y que por tanto las dependencias se redescubren en cada ejecución en vez de declararse una vez. Que haya tres estados y no dos te dice que la propagación se hace en dos fases, marcando primero y evaluando después. Que la derivación tenga valor te dice que se cachea, y que sea perezosa te dice que la evaluación se dispara desde la lectura. Que exista iguales te dice que la propagación se puede cortar en un nodo intermedio. Cinco campos, cinco propiedades del algoritmo. Esto no es una curiosidad: es cómo se lee código de sistemas de forma eficiente. Cuando te enfrentes a un motor desconocido, lee primero las definiciones de tipo o de estructura y deduce el algoritmo; luego lee el algoritmo solo para confirmar. Al revés —seguir el flujo de ejecución desde una función de entrada— tardarás diez veces más y entenderás la mitad. Es la misma razón por la que en una base de datos se lee el esquema antes que las consultas.

Lo obligatorio y lo opcional

No todos los campos son igual de necesarios, y separarlos ayuda a leer implementaciones que parecen distintas y no lo son.

Son obligatorios valor y observadores en la fuente, y fn, fuentes y estado en la computación. Sin ellos no hay motor.

Son optimizaciones todo lo demás, y cada motor real lleva las suyas. Un contador de versión por nodo, que permite comprobar si una fuente cambió desde la última lectura sin comparar valores. Una marca temporal de última actualización, que evita reevaluar dos veces en el mismo ciclo. Un puntero al dueño, que es el nivel 7. Índices cruzados entre las dos listas, para dar de baja una arista en tiempo constante, que es la lección 3 de este nivel.

Que un motor real tenga quince campos por nodo no significa que su algoritmo sea distinto: significa que ha pagado memoria para acelerar operaciones concretas. El esqueleto es siempre el mismo.

⚔️ Deduce el algoritmo desde la estructura
  1. Abre el código fuente de la parte reactiva de un motor cualquiera y localiza sus definiciones de nodo.
  2. Anota cada campo y escribe, sin mirar el algoritmo, qué operación lo necesita.
  3. Marca los campos que no sepas justificar y busca solo esos en el código.
  4. Comprueba cuántos resultaron ser optimizaciones y no piezas del esqueleto.