Angular Signals, Reactive Context, and Dynamic Dependency Tracking
Published: 2024-04-24 09:24:42


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](https://medium.com/@eugeniyoz/dependency-graph-in-angular-signals-53ee47f75e21) (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:

 1. Read the value of `activeConsumer` (to remember the previous consumer);
 2. Register themselves as an `activeConsumer`;
 3. Run the function or execute a template (some signals might be read during this step);
 4. 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:
```ts
    @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());
      }
    }
```

 1. The template reads the value of `activeConsumer` and saves it to the `prevConsumer` variable (this variable is local for the template);
 2. The template sets itself as `activeConsumer`;
 3. It calls `$items()` signal to get a value;
 4. `$items` signal retrieves the value of `activeConsumer`;
 5. 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;
 6. `$items` returns a value to the template;
 7. 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 to `computed()`;
 8. Before running the computation function, `$activeItemsCount` reads the value of `activeConsumer` and saves it to its local variable prevConsumer. Because `$activeItemsCount` is also a consumer, it puts a link to itself to the `activeConsumer` variable;
 9. Computation function calls `getActiveItems()` function;
 10. 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;
 11. When the value (an array of items) is returned, `getActiveItems()` reads every element of this array and reads the value of `$isActive()`;
 12. `$isActive` is a signal. So, before it returns a value, it repeats steps 3–6. During step 4, `$isActive` retrieves the value of `activeConsumer`. At this moment `activeConsumer` 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;
 13. `getActiveItems()` returns a value. `$activeItemsCount` uses this value for computation and before returning it, it puts the value of its local variable `prevConsumer` to the `activeConsumer` variable;
 14. `$activeItemsCount` returns a value;
 15. The template puts the previously saved value of `prevConsumer` to `activeConsumer`.

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](https://stackblitz.com/edit/stackblitz-starters-zvhx3t?file=src%2Fmain.ts):

{% embed https://stackblitz.com/edit/stackblitz-starters-zvhx3t?ctl=1&embed=1&file=src%2Fmain.ts&hideExplorer=1&view=preview %}

Please do the following in that app:

 1. Click button “2” to make it active, then click it again. Notice that the “Active items” text above the buttons reflects the change;
 2. Click the “Add Item” button;
 3. Click button “4”. Notice that “Active items” doesn’t reflect the change;
 4. Click button “2”;
 5. 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:
```ts
    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:
```html
    <div>Active items: {{ $activeItems() }}</div>
```

`$activeItems` is a signal, provided by `computed()`:
```ts
    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 the `effect()` 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).
```ts
    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 with `untracked()`:
```ts
    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](https://stackblitz.com/edit/stackblitz-starters-hgrfbo?file=src%2Fmain.ts):

{% embed https://stackblitz.com/edit/stackblitz-starters-hgrfbo?ctl=1&embed=1&file=src%2Fmain.ts&hideExplorer=1&theme=dark&view=preview %}

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?
```ts
    @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`:
```ts
    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:
```ts
    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)?
```ts
    /** 
     * 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 variable `prevConsumer`;
* runs the given function;
* restores `activeConsumer` from `prevConsumer`.

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](https://github.com/angular/angular/pull/54614#issue-2155451129) 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:

* [Rainer Hahnekamp](https://twitter.com/rainerhahnekamp)
* [Joshua Morony](https://twitter.com/joshuamorony)

---

🪽 Do you like this article? Share it and let it fly! 🛸

💙 *If you enjoy my articles, consider [following me on Twitter](https://twitter.com/eugeniyoz), and/or [subscribing](https://medium.com/@eugeniyoz) to receive my new articles by email.*