La topología: raíces, interior y hojas
Las propiedades estructurales del grafo reactivo — aciclicidad, profundidad, anchura y factor de ramificación — y cómo cada una predice un comportamiento concreto del motor en tiempo de ejecución.
El grafo reactivo no es un grafo cualquiera: es un grafo dirigido acíclico con una forma muy característica, y esa forma predice el comportamiento del motor mejor que cualquier otra medida. Profundidad, anchura y ramificación no son estadísticas ociosas: cada una se traduce en un coste concreto, en un modo de fallo concreto y en una técnica de optimización concreta.
- Justificar por qué el grafo tiene que ser acíclico y qué pasa si no lo es.
- Medir profundidad, anchura y factor de ramificación de un grafo reactivo.
- Predecir el coste de una propagación a partir de esas tres medidas.
- Reconocer las tres formas patológicas más frecuentes.
Por qué tiene que ser acíclico
Un ciclo en el grafo significa que un nodo depende, transitivamente, de sí mismo. a lee b, b lee a. Al escribir cualquiera de los dos, la propagación no termina nunca.
Los motores reales no lo prohíben estructuralmente, porque detectar ciclos en cada tejido de arista costaría más de lo que ahorra. Lo que hacen es defenderse en tiempo de ejecución de dos formas. Durante la evaluación, un nodo que se encuentra a sí mismo en la pila de evaluación lanza un error de dependencia circular; es lo que hace un computed en Vue y en Angular al detectar recursión.
La otra defensa es contra el ciclo indirecto: un efecto que escribe una fuente de la que depende. Ahí no hay recursión inmediata, sino una cascada de ciclos de propagación que se repite indefinidamente. La defensa habitual es un contador de iteraciones de la cola de efectos que aborta pasado un umbral, con un mensaje que menciona un bucle infinito de actualizaciones. El nivel 8 vuelve sobre esto con el detalle que merece.
El caso más frecuente no es el evidente sino este: un efecto que sincroniza dos fuentes en ambos sentidos. Si A escribe B cuando cambia y B escribe A cuando cambia, y las dos escrituras no convergen a un punto fijo, el sistema oscila. La comparación de igualdad de la fuente lo salva si y solo si la conversión de ida y vuelta es idempotente. Cuando no lo es —redondeos, formateo de cadenas, normalización— tienes un bucle infinito escrito con dos efectos que por separado parecen inofensivos.
Las tres medidas
Profundidad: la longitud del camino más largo de una raíz a una hoja. Es el número de niveles de derivación encadenados.
Anchura: el número de nodos en el nivel más poblado. Típicamente son las hojas, porque hay muchos más puntos de salida que valores de entrada.
Factor de ramificación: el número medio de observadores por nodo. Mide cuánto se abre la propagación al bajar un nivel.
flowchart TB R1[fuente usuario] --> D1[derivacion nombre completo] R1 --> D2[derivacion iniciales] R2[fuente tema] --> D3[derivacion clases css] D1 --> E1[efecto cabecera] D1 --> E2[efecto titulo del documento] D2 --> E3[efecto avatar] D3 --> E4[efecto raiz del documento] style R1 fill:#89b4fa,color:#11111b style R2 fill:#89b4fa,color:#11111b style D1 fill:#cba6f7,color:#11111b style D2 fill:#cba6f7,color:#11111b style D3 fill:#cba6f7,color:#11111b style E1 fill:#a6e3a1,color:#11111b style E2 fill:#a6e3a1,color:#11111b style E3 fill:#a6e3a1,color:#11111b style E4 fill:#a6e3a1,color:#11111b
Este grafo tiene profundidad tres, anchura cuatro y ramificación media alrededor de uno y medio. Es la forma sana: poco profundo y muy ancho. Las aplicaciones bien diseñadas tienden a esa forma porque hay pocas entradas reales y muchísimos puntos de salida.
Qué predice cada medida
La profundidad predice la latencia de una propagación y, sobre todo, la dificultad de garantizar el orden. Con profundidad uno, cualquier orden es correcto. Con profundidad diez, un motor sin garantías topológicas producirá glitches con seguridad. Además, en un modelo de pull, la profundidad es el número de saltos que hay que dar para resolver una lectura, y por tanto es directamente latencia.
La anchura predice el coste total de una propagación que llega abajo. Si una fuente afecta a mil hojas, se ejecutarán mil efectos, y si cada uno toca el DOM se producirán mil mutaciones. Es la medida que domina el peor caso.
La ramificación predice cuánto amplifica el grafo un cambio pequeño. Ramificación uno significa una cadena: un cambio produce una actualización por nivel. Ramificación diez significa que un cambio en la raíz toca diez nodos en el primer nivel y cien en el segundo. Ahí es donde los memos ganan su sueldo: un memo colocado en el punto de mayor ramificación corta la explosión si su valor no cambia.
Patologías y cómo detectarlas
Las tres formas patológicas
La cadena profunda. Diez derivaciones encadenadas, cada una dependiente de la anterior. Cada escritura recorre los diez niveles, y como cada nivel puede cortar por igualdad, la mayoría de las veces se recorre para nada. Casi siempre significa que hay derivaciones intermedias que no aportan y que se pueden fundir en una.
El cuello de botella. Un único nodo intermedio del que dependen cientos de hojas, típicamente un objeto de configuración o de estado global. Cualquier cambio en cualquiera de sus campos ensucia el nodo entero y con él las cientos de hojas, aunque cada hoja solo lea un campo. Es el argumento a favor de la reactividad atómica del nivel 9: partir ese nodo en cientos de fuentes independientes convierte una explosión en una actualización.
El grafo espagueti. Aristas cruzadas entre muchos niveles, con rombos por todas partes. Funciona —el motor garantiza el orden— pero es imposible de razonar, cada cambio tiene efectos a distancia y la depuración se vuelve arqueología. Es el equivalente reactivo del acoplamiento excesivo, y el remedio también es el mismo: introducir capas y prohibir los saltos entre niveles no adyacentes.
El error de diagnóstico más caro con motores reactivos es empezar por el perfilador. Un perfilador te dice qué función tardó, y en un sistema reactivo esa información casi nunca señala la causa: la función que tardó es la última hoja de una cascada cuyo origen está a seis niveles de distancia y no aparece en el perfil porque cada nodo intermedio costó microsegundos. Lo que necesitas es la forma del grafo, y se obtiene instrumentando el motor, no midiendo tiempos. Registra las aristas al tejerlas, vuelca el grafo, dibújalo y busca las tres patologías. En un grafo real encontrarás casi siempre lo mismo: un puñado de nodos con una ramificación desproporcionada, y todo el problema concentrado en ellos. Optimizar cualquier otra cosa es tiempo perdido. Hay además un corolario que se aprende a base de disgustos: el nodo culpable rara vez es el que más tarda, sino el que tiene más descendientes, y esos dos conjuntos casi nunca coinciden. Por eso la herramienta correcta es un volcado del grafo con el conteo de descendientes por nodo, y por eso todo motor serio acaba exponiendo, aunque sea en modo de desarrollo, una manera de inspeccionar sus aristas.
Instrumentar el grafo
Volcar el grafo es sorprendentemente barato. Basta con guardar un nombre por nodo al crearlo y recorrer las aristas.
function volcar(nodos) {
const lineas = [];
for (const n of nodos) {
const salida = n.observadores ? [...n.observadores] : [];
for (const o of salida) lineas.push(`${n.nombre} --> ${o.nombre}`);
}
return lineas.join('\n');
}
function descendientes(nodo, vistos = new Set()) {
for (const o of nodo.observadores ?? []) {
if (vistos.has(o)) continue;
vistos.add(o);
descendientes(o, vistos);
}
return vistos.size;
}
Con esas dos funciones y un ordenamiento por número de descendientes tienes el diagnóstico hecho. Los nodos de arriba de esa lista son donde hay que mirar; el resto es ruido.
- Instrumenta un motor para que cada nodo guarde un nombre y registre sus aristas al tejerlas.
- Vuelca el grafo de una pantalla real de tu aplicación y calcula profundidad, anchura y ramificación media.
- Ordena los nodos por número de descendientes y quédate con los cinco primeros.
- Comprueba si alguno de ellos corresponde a una de las tres formas patológicas y propón la partición que lo arregla.