Tracking automático de dependencias
Nunca declaras de qué depende un efecto: lo lees y el sistema lo sabe. El truco es un observador global y la disciplina de leer por el getter, con rastreo dinámico que recalcula las dependencias en cada ejecución y limpia las que quedaron obsoletas.
Lo más desconcertante de los signals la primera vez es lo que no tienes que hacer. Escribes un efecto que usa precio() e iva() y, sin que declares nada, el sistema sabe que ese efecto depende de esos dos signals y de ningún otro. No hay array de dependencias, no hay lista que mantener sincronizada. Esta lección abre la caja y enseña el truco, que resulta ser asombrosamente simple: una variable global que apunta al lector en curso y la regla de que todo signal se lee a través de su getter. De esa humilde combinación nace toda la magia.
- Entender el mecanismo del observador actual como una variable global.
- Ver cómo cada lectura registra la dependencia sin que la declares.
- Comprender el rastreo dinámico: las dependencias se recalculan en cada ejecución.
- Saber por qué hay que limpiar las dependencias que quedaron obsoletas.
El observador actual: una variable global
El secreto del rastreo automático es una sola variable, visible para todo el sistema, que guarda quién es el efecto que se está ejecutando ahora mismo. La llamaremos activo. Cuando un efecto va a correr, se coloca a sí mismo en activo, ejecuta su función y luego se retira. Mientras corre, cualquier signal que sea leído mirará activo, verá que hay alguien escuchando y lo registrará como dependiente.
type Efecto = () => void;
let activo: Efecto | null = null;
function efecto(fn: () => void): void {
const correr: Efecto = () => {
const previo = activo; // guardamos el contexto para poder anidar
activo = correr; // yo soy ahora el lector en curso
try {
fn(); // cada signal leido aqui me registra
} finally {
activo = previo; // restauramos al terminar
}
};
correr();
}
Fíjate en el patrón de pila: guardamos el activo previo, nos ponemos como activos y al terminar lo restauramos. Eso permite que un efecto contenga a otro sin que el hijo borre el rastro del padre. Es, en esencia, un ámbito dinámico implementado a mano: quién es el observador no depende de dónde está escrito el código, sino de qué se está ejecutando en este preciso instante.
Todo el mecanismo se apoya en una condición: que leer un signal sea siempre una llamada a función —el getter— y nunca un acceso a un campo plano. Solo una función puede ejecutar la línea que consulta activo y registra la dependencia. Por eso Solid usa precio() con paréntesis, Vue y Preact usan .value con un getter oculto, y Svelte reescribe tu código en compilación para insertar esas llamadas. Distintas fachadas, idéntica exigencia: interceptar el instante de la lectura.
Rastreo dinámico: las dependencias renacen en cada ejecución
Aquí está la sutileza que separa a los signals de los sistemas de dependencias declaradas a mano. Las dependencias no se fijan una vez: se vuelven a descubrir en cada ejecución del efecto. Antes de correr, el efecto se desconecta de todo; al correr, vuelve a registrarse solo en los signals que de verdad lee esta vez. Por eso las dependencias condicionales funcionan sin que hagas nada especial:
const [modo, ponerModo] = signal<"suma" | "resta">("suma");
const [a, ponerA] = signal(10);
const [b, ponerB] = signal(3);
efecto(() => {
if (modo() === "suma") {
console.log("resultado", a() + a()); // esta vez depende de modo y de a
} else {
console.log("resultado", b()); // esta vez dependeria de modo y de b
}
});
Mientras modo valga suma, el efecto depende de modo y de a, pero no de b: cambiar b no lo dispara. En el momento en que modo pasa a resta, la siguiente ejecución redescubre que ahora depende de modo y de b, y deja de depender de a. El grafo de dependencias respira con el flujo de control real del programa, no con lo que declaraste una vez y quedó congelado.
flowchart TD I[el efecto va a correr] --> P[se pone como observador activo] P --> E[ejecuta su funcion] E --> L[cada getter leido lo registra] L --> F[al terminar restaura el observador previo] F --> W[un signal del que depende cambia] W --> I style P fill:#cba6f7,color:#11111b style L fill:#89b4fa,color:#11111b style W fill:#fab387,color:#11111b
Limpiar lo obsoleto
Si las dependencias renacen en cada corrida, hay que borrar las viejas o el efecto seguiría reaccionando a signals que ya no lee. Un sistema completo mantiene el enlace en los dos sentidos: cada signal conoce a sus dependientes y cada efecto conoce sus fuentes. Antes de re-ejecutar, el efecto se da de baja de todas sus fuentes anteriores; luego, al leer, se vuelve a dar de alta solo donde toca.
// version con enlace bidireccional y limpieza
function efecto(fn: () => void): void {
const fuentes = new Set<Set<Efecto>>(); // conjuntos donde estoy suscrito
const correr: Efecto = () => {
for (const s of fuentes) s.delete(correr); // baja de las fuentes viejas
fuentes.clear();
const previo = activo;
activo = correr;
try { fn(); } finally { activo = previo; }
};
correr();
}
Sin esta limpieza aparecen dos plagas: fugas de memoria, porque un signal retiene para siempre a efectos que ya no le interesan, y disparos fantasma, porque un efecto reacciona a un dato del que dejó de depender. El rastreo automático solo es fiable si es simétrico: tan diligente registrando como olvidando.
Hay una frontera que atrapa a todos tarde o temprano: solo se rastrea lo que se lee mientras el efecto corre de forma síncrona. Si lees un signal dentro de un setTimeout, tras un await o en un callback diferido, en ese momento activo ya volvió a ser nulo y la lectura no registra nada. La dependencia se pierde en silencio y el efecto no vuelve a reaccionar. La regla práctica: lee tus signals arriba, de forma síncrona, y solo después entra en lo asíncrono con los valores ya capturados.
Rastrear o no rastrear: el escape de untrack
Si toda lectura síncrona registra una dependencia, ¿qué haces cuando quieres consultar el valor de un signal sin suscribirte a él? El caso aparece constantemente: registrar un log con el identificador del usuario, leer una configuración que no debería re-disparar el efecto, o romper un ciclo en el que un efecto lee lo mismo que escribe. La respuesta es un primitivo llamado untrack, y su implementación es de una sencillez desarmante: apaga el observador un instante.
function untrack<T>(fn: () => T): T {
const previo = activo;
activo = null; // durante fn nadie escucha: ninguna lectura registra
try {
return fn();
} finally {
activo = previo; // se restaura el observador anterior
}
}
Con él puedes mezclar, dentro de un mismo efecto, lecturas que crean dependencia y lecturas que no. Aquí el efecto reacciona al precio, pero solo consulta usuario de pasada, sin quedar atado a sus cambios:
efecto(() => {
const p = precio(); // SI dependemos del precio
const quien = untrack(() => usuario()); // leemos usuario sin depender de el
console.log(quien, "vio el precio", p);
});
Sin untrack, esa lectura incidental de usuario inflaría el grafo con una arista que nunca quisiste, y el efecto se dispararía cada vez que cambiara el nombre del usuario, aunque el precio siguiera igual. El escape mantiene honesto el grafo automático: te deja decir lee, pero no me suscribas. Es la contraparte necesaria del rastreo implícito —tan importante como él— porque devuelve al programador el control fino sobre qué relaciones cuentan y cuáles son mero paso.
Lo que parece magia es, en realidad, una de las ideas más antiguas de los lenguajes de programación reutilizada con una elegancia nueva: el ámbito dinámico. La variable activo no es más que una variable de contexto implícito que responde a la pregunta quién está observando ahora mismo, y su valor no depende de la estructura léxica del programa —de dónde escribiste el código— sino de su estructura temporal: qué se está ejecutando en este instante. Cada lectura de un signal consulta ese contexto y, si hay alguien, cose una arista entre el productor y el consumidor. La consecuencia profunda es que el grafo de dependencias deja de ser algo que el programador dibuja y pasa a ser algo que el programa descubre de sí mismo mientras se ejecuta, sincronizado con su propio flujo de control. Ese grafo es dinámico, exacto y autolimpiante: exacto porque contiene solo las dependencias que de verdad se leyeron, no las que imaginaste al escribir un array; dinámico porque se rehace en cada corrida siguiendo las ramas que realmente se tomaron; autolimpiante porque lo que no se vuelve a leer se da de baja. Compáralo con el array de dependencias que otros modelos te obligan a mantener a mano: allí tú eres el responsable de que la lista coincida con lo que el código usa, y cada divergencia es un bug —un efecto que no reacciona cuando debía, o que reacciona de más—. El rastreo automático elimina de raíz esa clase entera de errores trasladando la verdad de tu memoria falible al mecanismo infalible de la lectura misma. Comprender esto es entender por qué los signals se sienten menos como una librería y más como una propiedad del lenguaje: no gestionas dependencias, las contraes con solo mirar.
- Implementa la versión con
activoy comprueba que un efecto que lee dos signals reacciona a ambos sin que declares nada. - Escribe un efecto con una rama
ify demuestra que sus dependencias cambian según la rama tomada. - Quita la limpieza de fuentes y provoca un disparo fantasma: haz que un efecto reaccione a un signal que dejó de leer.
- Lee un signal dentro de un
setTimeouty verifica que la dependencia no se registra; luego arréglalo leyendo antes de forma síncrona. - Explica por qué el patrón de pila con
previoes imprescindible para efectos anidados.