wandres.dev
EL GRAFO REACTIVO · observers y sources

Los nodos del grafo: observers y sources

El grafo reactivo de Solid por dentro: los dos tipos de nodo (SignalState y Computation), la doble naturaleza del memo, y la lista de suscripción bidireccional con slots cruzados que permite tejer y desatar dependencias en tiempo constante.

⏱ 16 min

Todo lo que llamas reactividad en Solid es, literalmente, un grafo dirigido en memoria: un puñado de objetos enlazados entre sí. En este nivel abrimos el capó y miramos las estructuras reales. Hoy toca lo más básico y lo más importante: los nodos y las aristas. Verás que solo hay dos clases de nodo —fuentes y computaciones— y que se conectan mediante una lista doble y bidireccional, diseñada con una obsesión concreta: poder añadir y quitar una arista en tiempo constante, sin recorrer nada.

🎯 Al terminar esta lección sabrás
  • Distinguir los dos nodos del núcleo: SignalState y Computation.
  • Entender por qué un memo es, en el mismo objeto, fuente y computación.
  • Reconstruir la lista bidireccional: observers, sources y sus slots.
  • Comprender por qué los slots cruzados dan altas y bajas en O(1).

Dos estructuras, un solo grafo

En el archivo signal.ts del núcleo viven dos formas. Una fuente —SignalState— guarda un valor y una lista de quién lo observa. Una computación —Computation— guarda una función que re-ejecutar y una lista de a quién lee. Simplificadas, son así:

interface SignalState<T> {
  value: T;
  observers: Computation<any>[] | null;   // quien me lee
  observerSlots: number[] | null;         // mi indice en las sources de cada lector
  comparator?: (a: T, b: T) => boolean;   // el equals
}

interface Computation<T> {
  fn: (prev: T) => T;                      // el cuerpo a re-ejecutar
  state: number;                           // 0 limpio, 1 STALE, 2 PENDING
  sources: SignalState<any>[] | null;      // a quien leo
  sourceSlots: number[] | null;            // mi indice en los observers de cada fuente
  owner: Owner | null;                     // el ambito duenno
  updatedAt: number | null;                // en que tick corri por ultima vez
  pure: boolean;                           // memo (true) o efecto (false)
}

Un createSignal produce un SignalState; un createEffect produce un Computation con pure en false. La distinción pure decide en qué cola se encola la actualización, algo que verás en la lección 2. Por ahora fija la asimetría: una fuente solo produce (tiene observers, no sources); un efecto solo consume (tiene sources, no observers).

Hay un tercer productor de Computation que conviene nombrar desde ya: el propio JSX. Cada expresión reactiva de una plantilla —un contador() interpolado en el texto, un atributo dinámico, una clase condicional— la compila Solid a un pequeño efecto de render, una Computation con pure en false cuyo cuerpo escribe directamente en el DOM. Así que las hojas del grafo no son abstractas: son, literalmente, los puntos exactos donde tu interfaz toca la pantalla. Retén esta imagen del grafo completo —signals como raíces, memos en el centro, efectos de render como hojas que mueven píxeles—, porque es la que explica por qué Solid no necesita un virtual DOM: la arista ya termina en el nodo del DOM correcto.

El memo: fuente y computación fundidas

¿Y el memo? Es el nodo que rompe la asimetría. Un createMemo es un objeto que hereda las dos interfaces a la vez: tiene sources porque lee otros nodos, y tiene observers porque otros lo leen a él.

// Un memo es literalmente la union de ambas caras
interface Memo<T> extends SignalState<T>, Computation<T> {}

Esto no es una metáfora didáctica: es la definición real. El memo es el único nodo con las cuatro listas —sources, sourceSlots, observers, observerSlots— pobladas. Por eso ocupa el centro del grafo, mientras los signals son raíces y los efectos son hojas.

flowchart LR
S1[signal a] --> M[memo total]
S2[signal b] --> M
M --> E[effect render]
style S1 fill:#89b4fa,color:#11111b
style S2 fill:#89b4fa,color:#11111b
style M fill:#cba6f7,color:#11111b
style E fill:#a6e3a1,color:#11111b

Leído de izquierda a derecha, el grafo dice: a y b son observados por total, que a su vez es observado por el efecto de render. Cada flecha es una arista, y cada arista está registrada dos veces: una en cada extremo. Ese doble registro es el tema del resto de la lección.

La lista bidireccional y sus slots

Cuando una computación lee una fuente, hay que anotar el enlace en ambos lados: la fuente debe saber a quién notificar, y la computación debe saber de qué desuscribirse cuando se recolecte. Solid no usa una lista enlazada de punteros: usa arrays paralelos con índices cruzados. Este es el corazón de readSignal, simplificado:

function readSignal(this: SignalState<any>) {
  if (Listener) {
    // indice que ocupara Listener dentro de MIS observers
    const slot = this.observers ? this.observers.length : 0;
    // 1) yo, el signal, paso a ser una source del Listener
    (Listener.sources ??= []).push(this);
    (Listener.sourceSlots ??= []).push(slot);
    // 2) el Listener pasa a ser un observer mio
    (this.observers ??= []).push(Listener);
    (this.observerSlots ??= []).push(Listener.sources.length - 1);
  }
  return this.value;
}

Fíjate en la simetría. Listener.sources y this.observers son las dos mitades de la misma arista. Pero además guardamos dos números: sourceSlots recuerda en qué posición quedó el Listener dentro de this.observers; observerSlots recuerda en qué posición quedó this dentro de Listener.sources. Son punteros de vuelta, índices que se apuntan mutuamente.

Un detalle que delata la obsesión por el coste: las cuatro listas se crean de forma perezosa, con el operador ??=, solo en la primera suscripción. Un signal que todavía nadie ha leído dentro de un ámbito reactivo tiene su observers en null y no paga ni un byte por adelantado. La estructura crece exactamente al ritmo de las dependencias reales del programa, jamás por anticipado, y por eso un grafo con miles de signals inactivos apenas ocupa memoria.

flowchart TB
S[signal S] -->|observers push C| C[computation C]
C -->|sources push S| S
S -.observerSlots.-> C
C -.sourceSlots.-> S
style S fill:#89b4fa,color:#11111b
style C fill:#cba6f7,color:#11111b
ℹ️
Por qué arrays y no una lista enlazada

Una lista enlazada clásica facilita el borrado en O(1) pero paga con mala localidad de caché y una asignación por nodo. Solid prefiere arrays contiguos —rápidos de recorrer al notificar— y recupera el borrado en O(1) con el truco de los slots. Es una decisión de ingeniería de rendimiento: el caso frecuente, iterar observers para propagar, se hace sobre memoria contigua; el caso de baja, quitar una arista, se hace con un intercambio.

Bajas en tiempo constante

Antes de cada re-ejecución, una computación se desuscribe de todo para volver a descubrir sus dependencias desde cero (lo viste en el nivel 4). Si observers fuese un array normal, quitar a un observador costaría O(n) por el indexOf y el desplazamiento. Los slots convierten esa baja en un intercambio con el último elemento —el clásico swap-remove—:

function cleanNode(node: Computation<any>) {
  while (node.sources && node.sources.length) {
    const source = node.sources.pop()!;
    const index = node.sourceSlots!.pop()!;        // donde estaba yo en source.observers
    const obs = source.observers!;
    const last = obs.pop()!;                         // saco al ultimo observer
    const lastSlot = source.observerSlots!.pop()!;
    if (index < obs.length) {                        // si yo no era el ultimo...
      obs[index] = last;                             // muevo al ultimo a mi hueco
      source.observerSlots![index] = lastSlot;
      last.sourceSlots![lastSlot] = index;           // y reparo su indice cruzado
    }
  }
}

La línea decisiva es la última: al mover last al hueco que yo dejo, su sourceSlot guardado quedaría obsoleto, así que lo reparamos en el acto. Sin esa reparación, el índice cruzado mentiría y la próxima baja corrompería la lista. Es delicado, pero es lo que compra desuscribir un grafo entero en tiempo proporcional al número de aristas, no al cuadrado.

Merece la pena cuantificar la ganancia. Con una implementación ingenua —observadores en un array recorrido con indexOf y luego desplazado—, dar de baja una computación con k fuentes, en un grafo donde cada fuente tiene en promedio m observadores, costaría del orden de k por m. Con slots cruzados cuesta O(k): tiempo constante por arista, sin recorrer ni desplazar nada. En un componente que se re-suscribe en cada tick, esa distancia entre lo cuadrático y lo lineal es, a escala, la diferencia entre una interfaz fluida y una que se arrastra.

📝
El orden de observers no importa, y por eso se puede intercambiar

El swap-remove baraja la lista: el último observador salta al hueco del que se marcha, así que observers no conserva el orden de suscripción. ¿No rompe eso nada? No, y la razón es profunda. La corrección de la propagación no depende del orden en que un signal recorre a sus observadores, sino del orden topológico que imponen el marcado PENDING y la resolución hacia arriba de la lección 3. Como el orden de este array es irrelevante para el resultado final, Solid puede permitirse barajarlo, y es justo esa libertad la que habilita el borrado en tiempo constante. Una estructura obligada a preservar el orden no podría usar swap-remove: pagaría el borrado con un desplazamiento lineal.

El grafo es la estructura de datos; todo lo demas es recorrerla

Detente aquí, porque esta lección contiene, en germen, todo el nivel. La reactividad de Solid no es un concepto etéreo: es un grafo de objetos con cuatro arrays, y cada primitivo que conoces se reduce a operar sobre él. createSignal crea un nodo raíz. createEffect crea una hoja. createMemo crea el nodo central que hereda ambas caras. Leer teje una arista bidireccional. Escribir la recorre para notificar. Recolectar la deshace con un intercambio. No hay virtual DOM, no hay diffing, no hay heurística: hay un grafo que se mantiene exacto en cada instante porque las aristas son las lecturas reales, anotadas en el momento en que ocurren. Cuando en las próximas lecciones hablemos de propagación, de estados sucios o de orden topológico, no estaremos añadiendo maquinaria nueva: estaremos describiendo cómo se recorre este grafo. Interioriza las cuatro listas y sus slots cruzados, y el resto de la reactividad deja de ser magia para volverse geometría.

⚔️ Reconstruye el grafo a mano
  1. Dibuja en papel un signal a, un memo doble = a() * 2 y un efecto que lee doble(). Anota, para cada nodo, el contenido de sus arrays observers y sources.
  2. Marca los índices cruzados: ¿qué número guarda observerSlots del signal y qué número guarda sourceSlots del memo? Comprueba que se apuntan entre sí.
  3. Simula una recolección del memo: aplica el swap-remove sobre los observers del signal y verifica que el índice de vuelta queda reparado.
  4. Explica en una frase por qué el memo es el único de los tres nodos que tiene las cuatro listas pobladas.
  5. Razona qué pasaría con la integridad de la lista si cleanNode olvidara la última línea, la que repara last.sourceSlots.