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

Aristas en los dos sentidos

Por qué cada dependencia se registra dos veces, qué operación necesita cada dirección, y qué se rompe exactamente si eliminas una de las dos para ahorrar memoria.

⏱ 16 min

Cada dependencia del grafo se anota dos veces: una en la fuente, que apunta a quien la lee, y otra en el lector, que apunta a la fuente. Duplicar la información parece un derroche evitable, y las dos primeras veces que alguien implementa un motor intenta quitar una de las dos. Esta lección explica por qué no se puede, mostrando la operación concreta que cada dirección hace posible y el fallo exacto que aparece al eliminarla.

🎯 Al terminar esta lección sabrás
  • Distinguir la arista hacia adelante de la arista hacia atrás por su uso.
  • Implementar el tejido simultáneo de ambas en la lectura.
  • Ver qué falla al eliminar cada una de las dos direcciones.
  • Entender por qué la simetría hay que mantenerla como invariante.

Las dos direcciones

Llamemos hacia adelante a la arista que va de la fuente a su observador: fuente.observadores contiene la computación. Y hacia atrás a la que va del observador a su fuente: computacion.fuentes contiene la fuente.

Son la misma dependencia representada dos veces. Cuando total lee precio, hay que ejecutar las dos anotaciones a la vez.

function seguir(fuente) {
  if (!Observador) return;              // lectura fuera de ambito reactivo
  Observador.fuentes.add(fuente);       // hacia atras
  fuente.observadores.add(Observador);  // hacia adelante
}

Cuatro líneas y ya está todo el tejido del grafo. Lo notable es que ninguna de las dos anotaciones sirve para lo mismo que la otra.

Para qué sirve cada una

Hacia adelante, para propagar. Cuando se escribe una fuente hay que avisar a quien depende de ella. Sin esta dirección no hay forma de encontrar a los afectados salvo recorriendo todos los nodos del programa y preguntándoles si les importa. Es la dirección que todo el mundo implementa primero porque es la obvia.

function marcar(fuente) {
  for (const obs of fuente.observadores) obs.estado = SUCIO;
}

Hacia atrás, para desatar. Antes de reejecutar una computación hay que borrarla de las listas de observadores de todas sus fuentes anteriores, porque después de la reejecución sus dependencias pueden ser otras. Para borrarse de esas listas hay que saber cuáles son.

function desatar(nodo) {
  for (const f of nodo.fuentes) f.observadores.delete(nodo);
  nodo.fuentes.clear();
}

Aquí está la clave, y es la razón por la que la segunda dirección es innegociable: las dependencias no son estáticas. Un cuerpo con una condicional lee unas fuentes u otras según el valor de la condición. Cada ejecución produce un conjunto de dependencias potencialmente distinto, y sin la dirección hacia atrás no hay manera de limpiar el conjunto anterior. Es el tema del nivel 3.

flowchart LR
F[fuente] -->|observadores| C[computacion]
C -->|fuentes| F
style F fill:#89b4fa,color:#11111b
style C fill:#cba6f7,color:#11111b

Qué se rompe si quitas una

Vale la pena ver los dos fallos concretos, porque son instructivos.

Sin la dirección hacia adelante, escribir una fuente no encuentra a nadie. La única alternativa es que cada computación consulte a sus fuentes cuando alguien la lee, comprobando si alguna cambió. Eso es un motor de pull puro, y funciona: es un diseño legítimo que existe. Lo que pierdes es la capacidad de ejecutar efectos, porque un efecto no lo lee nadie y por tanto nunca se dispara. Un motor de pull puro necesita que alguien pregunte periódicamente, es decir, un bucle de sondeo. El nivel 4 lo analiza en detalle.

Sin la dirección hacia atrás, no puedes desatar. Las aristas se acumulan: una computación que en la primera ejecución leyó a y en la segunda lee b queda observando a las dos para siempre. El síntoma es un sistema que hace cada vez más trabajo con el tiempo y que reejecuta computaciones por cambios en valores que ya no lee. Y hay algo peor: sin la dirección hacia atrás no puedes borrar un nodo del grafo, así que un componente destruido sigue en las listas de observadores de todas sus fuentes, manteniendo vivos por referencia sus cierres, su DOM y todo lo que capturó. Es la fuga clásica de los sistemas reactivos.

🛑
La fuga por arista huerfana es la mas cara de todas

Una arista hacia adelante que sobrevive al nodo que la creó no solo hace trabajo inútil: retiene memoria. La fuente guarda una referencia a la computación, la computación guarda su cierre, el cierre guarda las variables que capturó, y entre ellas suele haber nodos del DOM ya desconectados. Un solo efecto no dado de baja en un componente de lista puede retener el subárbol entero de esa fila. Multiplicado por cada navegación, la aplicación crece hasta morir. Por eso el árbol de dueños del nivel 7 no es una comodidad: es el mecanismo que garantiza que esa baja ocurra siempre.

Mantener la simetría

La simetría como invariante

La condición que hay que mantener en todo momento es sencilla de enunciar y fácil de romper: si f está en c.fuentes, entonces c está en f.observadores, y viceversa.

Todas las operaciones sobre el grafo tienen que preservarla, y por eso conviene que ninguna toque una sola dirección. En el motor del nivel 12, las dos únicas funciones que modifican aristas son seguir y desatar, y ambas tocan los dos lados. Cualquier código que añada a observadores sin añadir a fuentes está introduciendo un bug que se manifestará mucho después y en otro sitio.

// Verificador de la invariante, util en pruebas
function comprobarSimetria(nodos) {
  for (const n of nodos) {
    if (n.fuentes) for (const f of n.fuentes) {
      if (!f.observadores.has(n)) throw new Error('arista hacia atras sin su pareja');
    }
    if (n.observadores) for (const o of n.observadores) {
      if (!o.fuentes.has(n)) throw new Error('arista hacia adelante sin su pareja');
    }
  }
}

Merece la pena tener esa comprobación en las pruebas de cualquier motor que escribas. Los bugs de asimetría son silenciosos y aparecen a mucha distancia de su causa.

La bidireccionalidad es lo que hace posible el grafo dinamico

La razón profunda de las dos direcciones no es la eficiencia sino algo más ambicioso: es lo que permite que el grafo se reconstruya solo en cada ejecución. Compara con un sistema donde las dependencias se declaran a mano, como una lista de dependencias escrita por el programador. Ahí solo hace falta una dirección, porque el conjunto de aristas es fijo y lo mantiene una persona. En cuanto quieres que el motor descubra las dependencias observando la ejecución, aceptas que cambien en cada pasada, y en cuanto cambian necesitas poder revertir la pasada anterior. Desatar es la operación inversa de tejer, y ninguna operación tiene inversa si no guardas de dónde vienes. Dicho con más fuerza: la arista hacia atrás es el precio exacto del tracking automático. Todos los motores que descubren dependencias la pagan, y ninguno que las declare a mano la necesita. Cuando en el nivel 11 veamos qué puede mover un compilador a tiempo de build, esta será una de las preguntas interesantes: si el compilador conociera el conjunto de dependencias con certeza, ¿podría ahorrarse la mitad del grafo? La respuesta corta es que casi nunca lo conoce con certeza, y por eso ningún compilador real elimina esta estructura.

Lo que sigue

Sabemos que hacen falta las dos direcciones. Lo que aún no hemos decidido es con qué estructura de datos representarlas: un Set, un array con índices cruzados, o una lista doblemente enlazada. Los motores reales han elegido las tres, por razones distintas, y esa es la lección siguiente.

⚔️ Rompe la simetria a proposito
  1. Toma el motor mínimo de la lección 3 del nivel 1 y elimina la línea que añade a fuentes.
  2. Escribe un efecto con una condicional que lea fuentes distintas según una bandera.
  3. Cambia la bandera varias veces y cuenta cuántas veces se reejecuta el efecto por cada escritura.
  4. Explica, con la lista de observadores de cada fuente en la mano, por qué el número crece.