DEV Community

Cover image for Angular Signals, Reactive Context, and Dynamic Dependency Tracking
Evgeniy OZ
Evgeniy OZ

Posted on • Originally published at Medium

Angular Signals, Reactive Context, and Dynamic Dependency Tracking

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:

  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:

    @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());
      }
    }
Enter fullscreen mode Exit fullscreen mode
  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:

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:

    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),
        });
      }
    }
Enter fullscreen mode Exit fullscreen mode

Now let’s analyze why the “Active items” line is not updated correctly.

Binding in our template:

    <div>Active items: {{ $activeItems() }}</div>
Enter fullscreen mode Exit fullscreen mode

$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(', ');
    });
Enter fullscreen mode Exit fullscreen mode

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).

    this.$petWalkingIsAllowed = computed(() => {
      return this.$isFreeTime() && this.isItGoodWeatherOutside();
    });

    isItGoodWeatherOutside() {
      return $isSunny() && $isWarm() && !$isStormy();
    }
Enter fullscreen mode Exit fullscreen mode

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():
    this.$petWalkingIsAllowed = computed(() => {
      return this.$isFreeTime() && untracked(() => this.$isItGoodWeatherOutside());
    });

    isItGoodWeatherOutside() {
      return $isSunny() && $isWarm() && !$isStormy();
    }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
    }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
      }
    }
Enter fullscreen mode Exit fullscreen mode
  • 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 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)

Collapse
 
jangelodev profile image
João Angelo

Hi Evgeniy OZ,
Your tips are very useful.
Thanks for sharing.