To effectively use Angular Signals, it’s crucial to understand the concept of the “reactive context” and how dependency tracking works. In this article, I’ll explain both these things, and show how to avoid some related bugs.
Dependency Tracking
When you use Angular Signals, you don’t need to worry about subscribing and unsubscribing. To understand how it works, we’ll need a few terms:
- Dependency Graph: graph of nodes, every node implements ReactiveNode interface;
- Producers: nodes that contain values and notify about new values (they “produce reactivity”);
- Consumers: nodes that read produced values (they “consume reactivity”);
Signals are producers, computed()
is producer and consumer simultaneously, effect()
is consumer, templates are consumers.
You can read a more detailed article about the Dependency Graph in Angular Signals (with animated graph example).
How automatic dependency tracking works: there is a variable, global for all the reactive nodes, activeConsumer
, and every time computed()
runs its computation function, every time effect()
runs its side-effects function, or a template is being checked for changes, they:
- Read the value of
activeConsumer
(to remember the previous consumer); - Register themselves as an
activeConsumer
; - Run the function or execute a template (some signals might be read during this step);
- Register the previous consumer (from Step 1) as an
activeConsumer
.
When any producer is read, it retrieves the value of activeConsumer
and includes this active consumer in the list of consumers dependent on the signal. When a signal is updated, it subsequently sends a notification to every consumer from its list.
Let’s examine what happens step by step in this example:
@Component({
template: `
Items count: {{ $items().length }}
Active items count: {{ $activeItemsCount() }}
`
})
class ExampleComponent {
protected readonly $items = signal([{id: 1, $isActive: signal(true) }]);
protected readonly $activeItemsCount = computed(() => {
return this.getActiveItems().length;
});
private getActiveItems() {
return this.$items().filter(i => i.$isActive());
}
}
- The template reads the value of
activeConsumer
and saves it to theprevConsumer
variable (this variable is local for the template); - The template sets itself as
activeConsumer
; - It calls
$items()
signal to get a value; -
$items
signal retrieves the value ofactiveConsumer
; - The received value is not empty (it contains a link to the template), so
$items
signal puts this value (link to our template) into the list of consumers. After that, every time $items is updated, the template will be notified — a new link has been created in the dependency graph; -
$items
returns a value to the template; - The template reads the value of
$activeItemsCount
signal. To return a value,$activeItemsCount
needs to run its computation function - the function we pass in our code tocomputed()
; - Before running the computation function,
$activeItemsCount
reads the value ofactiveConsumer
and saves it to its local variable prevConsumer. Because$activeItemsCount
is also a consumer, it puts a link to itself to theactiveConsumer
variable; - Computation function calls
getActiveItems()
function; - Inside this function, we read the value of
$items
— steps from 3 to 6 are repeated, but because our template is already dependent on$items
, Step 5 will not add a new consumer to the list; - When the value (an array of items) is returned,
getActiveItems()
reads every element of this array and reads the value of$isActive()
; -
$isActive
is a signal. So, before it returns a value, it repeats steps 3–6. During step 4,$isActive
retrieves the value ofactiveConsumer
. At this momentactiveConsumer
contains a link to$activeItemsCount
, so at step 5$isActive
(each one from the array) will add$activeItemsCount
to the list of dependent consumers. Any time$isActive
is updated,$activeItemsCount
will be notified,$activeItemsCount
will notify our template that its value is stale and needs to be recomputed. After that, our template eventually (not right after the notification) will ask$activeItemsCount
what is the new value, and steps from 7 to 14 will be repeated; -
getActiveItems()
returns a value.$activeItemsCount
uses this value for computation and before returning it, it puts the value of its local variableprevConsumer
to theactiveConsumer
variable; -
$activeItemsCount
returns a value; - The template puts the previously saved value of
prevConsumer
toactiveConsumer
.
It is not a short list, but please read it thoroughly.
The most important thing here is: that consumers (computed()
, effect()
, templates
) don’t need to worry about adding signals they read to the list of dependencies. Signals will do it themselves, using the activeConsumer
variable. This variable is accessible to any reactive node, so it doesn’t matter how deep in the functions chain some signal will be read — any signal will get the value of activeConsumer
and add it to the list of consumers.
Remember: if you call a function in a template, computed()
or effect()
(a consumer), and that function reads another function and that function reads another function…, and finally, at some level, a function reads a signal, and that signal adds that consumer to its list and will notify it about the updates.
Debugging-like reading can be tedious, so let me entertain you with this small app:
Please do the following in that app:
- Click button “2” to make it active, then click it again. Notice that the “Active items” text above the buttons reflects the change;
- Click the “Add Item” button;
- Click button “4”. Notice that “Active items” doesn’t reflect the change;
- Click button “2”;
- Now click button “4” a few times and notice that the “Active items” text reflects it as expected.
But why? Let’s check the code:
export type Item = {
id: number;
$isActive: WritableSignal<boolean>;
};
@Component({
selector: 'my-app',
template: `
<div>Active items: {{ $activeItems() }}</div>
<div>
<span>Click to to toggle:</span>
@for(item of items; track item.id) {
<button (click)="item.$isActive.set(!item.$isActive())"
[class.active]="item.$isActive()">
{{ item.id }}
</button>
}
</div>
<div>
<button (click)="addItem()">Add Item</button>
</div>
`,
})
export class App {
protected readonly items: Item[] = [
{ id: 1, $isActive: signal(true) },
{ id: 2, $isActive: signal(false) },
{ id: 3, $isActive: signal(true) },
];
protected readonly $activeItems = computed(() => {
const ids = [];
for (const item of this.items) {
if (item.$isActive()) {
ids.push(item.id);
}
}
return ids.join(', ');
});
protected addItem() {
this.items.push({
id: this.items.length + 1,
$isActive: signal(false),
});
}
}
Now let’s analyze why the “Active items” line is not updated correctly.
Binding in our template:
<div>Active items: {{ $activeItems() }}</div>
$activeItems
is a signal, provided by computed()
:
protected readonly $activeItems = computed(() => {
const ids = [];
for (const item of this.items) {
if (item.$isActive()) {
ids.push(item.id);
}
}
return ids.join(', ');
});
The function we pass to computed()
will be re-executed every time any of the signals it reads is updated. What signals do we read there?
It’s $isActive
signal of every item in the this.items
array.
Notice how the
$
sign in the name of the variable helps quickly find the sources of reactivity. This principle applies similarly to theeffect()
function and component templates. That’s why I use it, but it’s simply a matter of personal preference.
So why $activeItems
was not updated after steps 2 and 3?
The computation function will only be re-executed when one of the signals it depends on is updated.
When we click “Add Item”, we modify this.items
and create a new signal inside the new item. But before this moment, our computed()
function had never read that signal, so it does not have it in the list of dependencies.
Before and after clicking “Add Item,” the list of signals that $activeItems
depends on remains unchanged: three $isActive
signals from the three items in this.items
.
Because none of these signals is modified when we click “Add Item”, computed()
will not be notified and the computation function will not be re-executed.
We can toggle our new item in the list of buttons as many times as we want, but only the signals of the 3 first items will notify $activeItems and it will re-execute the function we sent.
But if we re-execute our computation function, it will read all the items from this.items
again and will read the new signal, finally. The new signal will become the new dependency of the $activeItems node, and it will be notified every time one of them is changed.
To do this, we need to modify one of the existing dependencies: that’s why we click button “2” in step 4.
This example is created to remind you, that functions we pass to computed()
and effect()
will only be re-executed, when one of the producers they read is updated.
This is why it is always useful to double-check, what dependencies your computed()
has and what of them should cause a re-computation. If some of them should not — use untracked()
.
Some of the functions we pass to computed()
or effect()
might read signals (or functions they call might read signals).
this.$petWalkingIsAllowed = computed(() => {
return this.$isFreeTime() && this.isItGoodWeatherOutside();
});
isItGoodWeatherOutside() {
return $isSunny() && $isWarm() && !$isStormy();
}
To understand if we should wrap such calls with untracked()
to avoid non-desired recomputations, we can use this logic:
- If we don’t want our
computed()
to compute a new result when that function (isItGoodWeatherOutside()
) returns a new value, then wrap it withuntracked()
:
this.$petWalkingIsAllowed = computed(() => {
return this.$isFreeTime() && untracked(() => this.$isItGoodWeatherOutside());
});
isItGoodWeatherOutside() {
return $isSunny() && $isWarm() && !$isStormy();
}
- If on every new value from that function we do want to re-run our computation, do not wrap it with
untracked()
.
As you can see, untracked()
helps us control which dependencies we want to track. It also helps to manage another important aspect:
Reactive Context
Above, in “How automatic dependency tracking works,” I’ve mentioned the variable activeConsumer
.
When activeConsumer
is not null, signals we read will add that activeConsumer
to the list of consumers, to later notify members of this list about modifications of a signal. If a reactive node is read while activeConsumer
is empty, it will not create any new link in the reactive nodes dependency graph.
In other words, while activeConsumer
is set, we are reading signals within the Reactive Context.
In the majority of cases, the reactive context will be handled automatically, and only the intended links and dependencies will be created and removed.
But sometimes we unintentionally leak the reactive context.
Let’s try out this app:
If you try to use it, you’ll notice that:
- clicking “Add Item” leads to a complete reset of all statuses;
- clicking to toggle the status changes it randomly, affecting more than just one button.
Can you quickly spot the bug?
@Component({
template: `
<div>Active items: {{ $activeItems() }}</div>
<div class="flex-row">
<span>Click to to toggle:</span>
@for(item of $items(); track item.id) {
<button (click)="item.$isActive.set(!item.$isActive())" [class.active]="item.$isActive()" [style.transform]="'scale('+item.$scale()+')'">
{{ item.id }}
</button>
}
</div>
<div>
<button (click)="addItem()">Add Item</button>
</div>
`,
})
export class App {
private readonly $itemsCount = signal(3);
protected readonly $items: Signal<Item[]> = computed(() => {
console.warn('Generating items!');
const items: Item[] = [];
for (let id = 0; id < this.$itemsCount(); id++) {
const $isActive = signal(Math.random() > 0.5);
const $scale = signal($isActive() ? 1.2 : 1);
items.push({ id, $isActive, $scale });
}
return items;
});
protected readonly $activeItems = computed(() => {
const ids = [];
for (const item of this.$items()) {
if (item.$isActive()) {
ids.push(item.id);
}
}
return ids.join(', ');
});
protected addItem() {
this.$itemsCount.update(c => c + 1);
}
}
What can we see here:
- We render the list of items from $items, which is
computed()
; -
$items
generates a new array of items, and their count is controlled by the$itemsCount
signal. Every time we modify $itemsCount, items are regenerated; -
addItem()
simply increments$itemsCount
, triggering recomputation of$items
.
Now we can see why “Add Item” works this way. Let’s try to figure out why the status toggling behaves strangely.
If we open the console, we’ll notice that every time we click a button, a “Generating items!” warning is logged. But why? We’re not modifying $itemsCount, so why is $items
recomputed?
Perhaps you’ve already noticed that the computation function of $items
reads one more source of reactivity: the signal $isActive
:
const $scale = signal($isActive() ? 1.2 : 1);
This signal ($isActive
) is being read in the reactive context: activeConsumer
contains $items
, so $isActive
will notify $items
about every change. Therefore, when we modify $isActive
in an attempt to toggle this status, we trigger recomputation of $items
.
There are multiple ways to fix this bug, but this approach prevents the leakage of the reactive context:
const $scale = signal(untracked($isActive) ? 1.2 : 1);
What does untracked()
do?
/**
* https://github.com/angular/angular/blob/75a186e321cb417685b2f13e9961906fc0aed36c/packages/core/src/render3/reactivity/untracked.ts#L15
*
* packages/core/src/render3/reactivity/untracked.ts
*
**/
export function untracked<T>(nonReactiveReadsFn: () => T): T {
const prevConsumer = setActiveConsumer(null);
try {
return nonReactiveReadsFn();
} finally {
setActiveConsumer(prevConsumer);
}
}
- sets
activeConsumer
to null and saves the returned value to the local variableprevConsumer
; - runs the given function;
- restores
activeConsumer
fromprevConsumer
.
It temporarily disables the reactive context, executes our function, and then restores the reactive context.
Because of that, while our function is being executed, if any signals are being read, they will read null from activeConsumer
and won’t add it to their lists of consumers. In other words, no new dependencies will be created.
In this example, we have some “hints” in the console, and our code is very small and simple. In real apps, signal reading might be buried deep within the function call chain, and the code could be much larger and more complex. Bugs like this can be challenging to debug in real apps, which is why I recommend preventing them by using untracked()
whenever you don't want to leak the reactive context.
There are quite interesting and unexpected ways to leak the reactive context:
- Creating an instance of a class that reads some signals;
- Calling a function that calls another function, which reads a signal;
- Emitting a new value to an observable.
When you use computed()
and effect()
,
- Read other signals with caution — they’ll rerun the entire function every time they change, triggered by any other function.
- Make these functions easy to read and understand;
- Double-check every source of reactivity that your function consumes.
As is often the case, implicit dependency tracking brings not only benefits but also some trade-offs. But when used with skill and caution, you can build wonderful apps with Angular Signals!
I extend my heartfelt gratitude to the reviewers whose insightful comments and constructive feedback greatly contributed to the refinement of this article:
🪽 Do you like this article? Share it and let it fly! 🛸
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
Top comments (1)
Hi Evgeniy OZ,
Your tips are very useful.
Thanks for sharing.