El observador actual: una variable de módulo
El mecanismo entero del tracking automático cabe en una variable mutable y un patrón de guardar y restaurar. Sus propiedades, sus límites y por qué es la pieza más frágil de cualquier motor reactivo.
La magia del tracking automático es una variable global. Literalmente: un hueco de módulo que contiene la computación que se está ejecutando ahora mismo, gestionado con el patrón de guardar el valor previo, asignar el nuevo, ejecutar y restaurar. De esa simplicidad salen todas sus virtudes y absolutamente todas sus limitaciones, y quien la interioriza deja de sorprenderse por los comportamientos raros de cualquier motor de señales.
- Implementar el observador actual y el patrón de guardar y restaurar.
- Explicar por qué el mecanismo forma una pila implícita sobre la de llamadas.
- Deducir qué situaciones rompen la asociación y por qué.
- Reconocer el nombre de esta variable en cuatro motores reales.
La variable y el patrón
Todo el mecanismo es esto.
let Observador = null; // la computacion en ejecucion, o null
function conObservador(nodo, fn) {
const anterior = Observador; // 1. guardar
Observador = nodo; // 2. asignar
try {
return fn(); // 3. ejecutar
} finally {
Observador = anterior; // 4. restaurar, pase lo que pase
}
}
Y su contraparte, la lectura, que consulta la variable para saber a quién conectar.
function leer(fuente) {
if (Observador) {
Observador.fuentes.add(fuente);
fuente.observadores.add(Observador);
}
return fuente.valor;
}
Eso es el tracking automático completo. No hay más. Todo lo demás en un motor —propagación, memoización, dueños, lotes— es maquinaria alrededor de estas dos funciones.
El finally no es opcional. Si el cuerpo lanza una excepción y no se restaura la variable, el motor queda con un observador colgado y todas las lecturas posteriores del programa se suscriben a un nodo muerto. Es un fallo que corrompe el grafo entero y se manifiesta en un sitio arbitrario mucho después. Todos los motores serios usan try y finally aquí, y merece la pena comprobarlo cuando leas uno.
Una pila implícita
Como las computaciones se anidan —una derivación lee otra derivación, que lee una tercera—, la variable se asigna y restaura en un patrón de pila, y esa pila es la propia pila de llamadas de JavaScript.
// Observador: null
conObservador(memoA, () => {
// Observador: memoA
conObservador(memoB, () => {
// Observador: memoB
leer(fuente); // la arista se teje hacia memoB
});
// Observador: memoA restaurado
leer(otraFuente); // esta arista va hacia memoA
});
// Observador: null
Que la pila sea implícita tiene una consecuencia útil: el anidamiento funciona gratis y de forma correcta, sin gestionar ninguna estructura. Y tiene otra consecuencia menos agradable: la asociación solo existe mientras el marco de pila esté vivo. En cuanto el control sale de esa función, la asociación se deshace.
flowchart TB A[Observador es null] --> B[entra memo A] B --> C[Observador es memo A] C --> D[entra memo B] D --> E[Observador es memo B] E --> F[lectura teje hacia memo B] F --> G[sale memo B] G --> H[Observador vuelve a memo A] H --> I[sale memo A] I --> J[Observador vuelve a null] style A fill:#94e2d5,color:#11111b style C fill:#cba6f7,color:#11111b style E fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b style H fill:#cba6f7,color:#11111b style J fill:#94e2d5,color:#11111b
Lo que rompe la asociación
De la naturaleza de pila se deducen, sin necesidad de consultar documentación, las tres situaciones que rompen el tracking en todos los motores.
Después de un await. El cuerpo se suspende, la función vuelve al bucle de eventos y la variable se restaura al salir del marco síncrono. Cuando la continuación se reanuda, Observador vale lo que valiera en ese momento, casi siempre null. Las lecturas posteriores al await no tejen nada.
efecto(async () => {
const id = usuarioId(); // SI se rastrea, es sincrono
const datos = await pedir(id);
const t = tema(); // NO se rastrea, el marco ya no existe
pintar(datos, t);
});
Dentro de un callback diferido. Un setTimeout, un requestAnimationFrame, un manejador de eventos registrado dentro del efecto: todos se ejecutan en un turno posterior, con la variable ya restaurada.
Dentro de otra computación anidada. Esta es la que confunde. Si dentro de un efecto creas otro efecto, las lecturas del interior se atribuyen al interior y no al exterior, porque la variable apunta al más profundo. Es el comportamiento correcto y deseable, pero produce sorpresa cuando el anidamiento no era intencionado.
Ninguna de las tres situaciones produce un error. El valor leído es correcto en ese instante; simplemente no queda registrada la dependencia. El síntoma es que algo deja de actualizarse, a veces solo por ciertos caminos de código. Por eso los motores maduros añaden avisos en desarrollo cuando detectan una lectura reactiva fuera de ámbito, y por eso conviene tenerlos activados.
El vocabulario y la primitiva de escape
Cómo se llama en cada motor
La variable existe en todos con el mismo papel y distinto nombre. En Solid es Listener. En Vue, tras la reescritura de su reactividad, el suscriptor activo. En Angular, el consumidor activo del grafo de señales. En Preact Signals, el contexto de evaluación. En la propuesta de TC39, es interno al motor de JavaScript, que es exactamente su gracia: al vivir en el lenguaje solo puede existir una copia.
Localizarla es la mejor forma de empezar a leer el código fuente de cualquiera de ellos. Busca la única variable de módulo mutable que se asigna dentro de un try con finally, y habrás encontrado el corazón.
Merece la pena detenerse en cuánto depende de un solo hueco de memoria. Primero, es la frontera de interoperabilidad: dos motores con dos variables distintas no comparten grafo, como vimos en el nivel 0, y por eso estandarizarla es todo el objetivo de la propuesta de Signals. Segundo, es la frontera de la asincronía: el tracking solo funciona dentro de un tramo síncrono de ejecución, lo que explica de un plumazo todas las advertencias sobre async de todos los motores, y explica también por qué las primitivas de datos asíncronos —recursos, suspense— necesitan maquinaria adicional en vez de salir gratis. Tercero, es la frontera de los hilos: un motor construido sobre una variable de módulo no puede repartir su grafo entre workers, porque cada worker tiene su propio módulo y su propia variable; cualquier propuesta de reactividad concurrente choca contra esto y tiene que resolverlo antes que nada. Tres limitaciones fundamentales de toda una familia de herramientas, las tres deducibles de una línea de código. Cuando alguien te presente un motor reactivo nuevo prometiendo resolver alguna de las tres, la primera pregunta que hay que hacerle es qué hizo con esta variable, porque no hay forma de esquivarla sin cambiar el diseño de raíz.
Leer sin rastrear
Si asignar la variable teje aristas, ponerla a null deja de tejerlas. De ahí sale una primitiva que todos los motores ofrecen.
function sinSeguir(fn) {
const anterior = Observador;
Observador = null;
try { return fn(); } finally { Observador = anterior; }
}
Se llama untrack en Solid, untracked en Angular y en Preact Signals, y en Vue el efecto equivalente se consigue leyendo la propiedad interna que no dispara el rastreo o usando toRaw sobre un objeto reactivo. La usarás cuando quieras el valor actual de algo sin depender de él: leer un contador para un log, comparar contra un valor previo, o cortar una dependencia que crearía un ciclo.
- Clona un motor de señales cualquiera y encuentra su variable de observador actual.
- Comprueba si el guardado y restaurado usa
tryyfinally, y qué pasaría si no lo usara. - Busca la implementación de su primitiva de leer sin rastrear y verifica que es el mismo patrón.
- Escribe un caso con
awaiten medio y demuestra, contando reejecuciones, que la segunda lectura no se rastrea.