Dependency Graph in Angular Signals
Evgeniy OZ
Posted on July 29, 2023
When you use RxJS observables, to start receiving emitted values from an observable, the listener should explicitly subscribe to that observable. When you use Angular Signals, this is handled implicitly by a dependency tracking mechanism.
Producers, Consumers
The first time when you read a signal value inside the function, passed to computed()
or effect()
, or read it in a template, new consumer-producer edges will be created in the dependency graph.
To better understand the terms producer and consumer, let’s take a look at this example:
const $isLoading = signal<boolean>(false);
const $isSaving = signal<boolean>(false);
const $isBusy = computed<boolean>(() => {
return $isLoading() || $isSaving();
});
Here $isLoading
, $isSaving
, and $isBusy
are producers [of reactivity], and the function, passed to computed()
, is a consumer — it consumes the reactivity, created by producers. This function exists to create a consumer, $isBusy
, and this fact makes $isBusy
a consumer and a producer simultaneously (and that’s ok).
Every producer knows its consumers, while consumers are aware of all of the producers on which they depend.
The body of the function passed to computed()
in our example is called “reactivity context”.
$isLoading
and $isSaving
are consumed in a reactivity context, so they are automatically registered as producers for $isBusy
, and now $isLoading
and $isSaving
will know $isBusy
as their consumer.
Dependency Graph
Dependencies are tracked automatically and recursively, information about the dependencies creates the Dependency Graph. The size of the graph depends on the number of signals in your application.
Let’s take a look at this dependency graph:
In this example, C1 and C6 are consumers only (template and effect()
).
C2, C3, C4, C5, C7, and C8 are signals, created with computed()
— they are consumers and producers at the same time.
P1-P6 are regular writeable signals, they are producers only.
When P2 will produce a value, C2 and C1 will be notified.
When P4 will produce a value, only C6 will be notified.
P6 will notify C4, C3, C2 and C1.
P7 will notify every consumer.
We don’t have to call subscribe()
or add observables to combineLatestWith()
or withLatestFrom()
. We don’t even have to care about unsubscribing — the edges will be removed automatically from the dependency graph when referenced producers or consumers are not being used anymore and are destroyed by the garbage collector.
But if we’ll complicate our example a little, we’ll find some interesting (and not so obvious) consequences of implicit (automatic) dependency tracking:
export class ExampleService {
private $audioListener = signal<AudioListener | undefined>(undefined);
constructor() {
window.document.addEventListener('click', () => {
const audioListener = new AudioListener()
this.$audioListener.set(audioListener);
}, { once: true, passive: true });
}
getAudioListener(): AudioListener | undefined {
return this.$audioListener();
}
}
export component ExampleComponent {
private service = inject(ExampleService);
private $hoverAudio = computed(() => {
const hoverAudioBuffer = this.$hoverAudioBuffer();
if (hoverAudioBuffer) {
// ⬇️
const listener = this.service.getAudioListener();
// ⬆️
if (listener) {
const hoverAudio = new PositionalAudio(listener);
hoverAudio.setBuffer(hoverAudioBuffer);
return hoverAudio;
}
}
return undefined;
});
}
The line, where we are getting a value for the listener
variable, doesn’t read a signal’s value explicitly, but it calls a function that reads a value from the signal $audioListener
. And still, this dependency will be added to the dependency graph! $audioListener
will be registered as a producer for $hoverAudio
signal. $hoverAudio
will be registered as a consumer for $audioListener
. It doesn’t matter how many levels of function we’ll call — dependency will be correctly registered.
There are pros and cons to this nested implicit tracking, as with any automated and implicit mechanism.
This example is a very simplified code from a real app I’m working on, and it was very helpful that I don’t have to expose the signal itself and can just expose a getter, and dependency tracking will do the rest.
But I realize that in some moments I might create a dependency non-intentionally. It might lead to non-needed updates and additional memory consumption.
We can control it:
private $hoverAudio = computed(() => {
const hoverAudioBuffer = this.$hoverAudioBuffer();
if (hoverAudioBuffer) {
// ⬇️ ⬇️
const listener = untracked(() => this.service.getAudioListener());
// ⬆️ ⬆️
if (listener) {
const hoverAudio = new PositionalAudio(listener);
hoverAudio.setBuffer(hoverAudioBuffer);
return hoverAudio;
}
}
return undefined;
});
But to do that, you should know the internals of getAudioListener()
. Also, a function that wasn’t reading from a signal today, might start reading it tomorrow.
There is one limitation in automated tracking: producers, consumed asynchronously, will not be registered:
const $isLoading = signal<boolean>(false);
const $isSaving = signal<boolean>(false);
const $isBusy: Signal<boolean> = computed(() => {
let isSaving;
setTimeout(() => isSaving = $isSaving(), 0);
return $isLoading() || isSaving;
});
In this example, $isSaving <-> $isBusy
edge will not be added to the graph.
Angular Signals are young, we need at least a couple of years to completely understand if the pros of automatic dependency tracking outweigh their cons, but right now I do like this feature — it helps me most of the time, and I use untracked()
quite rarely.
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.
Posted on July 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.