Angular Signals uses a dependency tracking mechanism for implicit subscriptions, creating a dependency graph with producers and consumers for efficient memory and computational usage.
Abstract
The provided content discusses Angular Signals, which utilizes a dependency tracking mechanism for implicit subscriptions to signals, unlike RxJS observables. The dependency graph created in this process involves producers, consumers, and edges. Producers are signals that create reactivity, while consumers are functions that consume this reactivity, and edges represent the dependencies between them. The graph is automatically and recursively tracked, and its size depends on the number of signals in the application. The content also covers dynamic dependency tracking, nested implicit tracking, and limitations of the tracking mechanism.
Bullet points
Angular Signals uses a dependency tracking mechanism for implicit subscriptions, unlike RxJS observables.
Producers create reactivity, and consumers are functions that consume this reactivity.
The dependency graph involves producers, consumers, and edges, with edges representing the dependencies between them.
The dependency graph is automatically and recursively tracked.
Dependency tracking is dynamic, allowing Angular to reduce memory and computational usage.
Nested implicit tracking occurs in Angular Signals, which can be controlled using the untracked() function.
The tracking mechanism has limitations, such as producers consumed asynchronously not being registered.
Angular Signals are young, and it will take time to determine if the pros of automatic dependency tracking outweigh the cons.
Dependency Graph in Angular Signals
“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:
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.
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:
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.
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:
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:
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.