Dependency Graph in Angular Signals

🪄 OZ 🎩
5 min readJul 29, 2023

--

“Almond Blossom”, Vincent van Gogh, 1890

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:

P — producers, C — consumers

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.

Dependency Tracking

If we’ll complicate our example a little, we’ll find some interesting (and not so obvious) consequences of implicit (automatic) dependency tracking in Angular Signals:

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;
});
}
  • Dependency Tracking in Angular Signals is recursive for synchronous function calls.

The line, where we are getting a value for the listener variable, doesn’t read a signal’s value explicitly (it looks like we are just fetching some value), 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 = computed<boolean>(() => {
let isSaving;
setTimeout(() => isSaving = $isSaving(), 0);
return $isLoading() || isSaving;
});

In this example, $isSaving <-> $isBusy edge will not be added to the graph.

  • Dependency Tracking in Angular Signals is dynamic.

It means, that $hoverAudio will not always have $audioListener as a dependency, but only when $hoverAudioBuffer returned some value and the next if branch is evaluated. This allows Angular to reduce the amount of needed memory and computations.

This rule can be quite important, and sometimes you might want to be notified about every new value in some signal, no matter what logical expression is being evaluated (and this might be especially important for the Angular’s effect() function).

In such cases, you can move signals reading out of conditional branches:

private $hoverAudio = computed(() => {
// ⬇️ read all the signals first
const hoverAudioBuffer = this.$hoverAudioBuffer();
const listener = this.service.getAudioListener();
// ⬆️

if (hoverAudioBuffer) {
if (listener) {
const hoverAudio = new PositionalAudio(listener);
hoverAudio.setBuffer(hoverAudioBuffer);
return hoverAudio;
}
}
return undefined;
});

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.

--

--