Angular Signals: Keeping the Reactivity Train

Angular Signals: Keeping the Reactivity Train

There is a very underrated tweet by Pawel Kozlowski:

In this article, let’s apply examples from that MobX article to Angular Signals.

In the examples, I will prefix variables containing signals with the $ symbol: $itIsSignal. Variables and fields without this prefix are not signals. After reading this article, you might agree that it helps to see what should be called as a function to be read (and observed). Or you might decide not to use this convention  -  it’s up to you, I don’t insist :)

Examples are “converted” from the MobX article examples, but I’ll replace the term “dereferencing” with “reading” and “tracking function” with “watching function” (or just watcher()), because in Angular Signals, a signal needs to be read inside a watching function to become observed.

Right now, Angular has two “watchers” implemented: the effect() function and the templates. They are implementing the same role in Angular Signals reactivity, so I’ll use watcher() to reference both of them.

Let’s start already:

import { signal, WritableSignal } from "@angular/core";

type Author = {
  $name: WritableSignal<string>;
  age: number;
};

class Message {
  $title: WritableSignal<string>;
  $author: WritableSignal<Author>;
  $likes: WritableSignal<string[]>;

  constructor(title: string, author: string, likes: string[]) {
    this.$title = signal(title);

    this.$author = signal({
      $name: signal(author),
      age: 25,
    });

    this.$likes = signal(likes);
  }

  updateTitle(title: string) {
    this.$title.set(title);
  }
}

let message = new Message('Foo', 'Michel', ['Joe', 'Sara']);
Correct: reading inside the watching function
watcher(() => {
  console.log(message.$title());
});

message.updateTitle('Bar');

Here, we’ll receive the expected update in the console because watcher() has read the $title, and after that, it will re-read this signal when receive an update notification.

Incorrect: changing a non-observable reference
watcher(() => {
  console.log(message.$title());
});

message = new Message('Bar', 'Martijn', ['Felicia', 'Marcus']);

Here, we replace the message, but watcher() uses the reference to another variable, and it will not be notified that we’ve replaced the reference.

Incorrect: reading a signal outside of the watching function
const title = message.$title();

watcher(() => {
console.log(title);
});

message.updateTitle('Bar');

Here, title is not a signal. It’s the value we’ve read outside of the watching function, so watcher() will not be notified when we update the signal.

Correct: reading nested signal inside the watching function
watcher(() => {
  console.log(message.$author().$name());
});

message.$author().$name.set("Sara");

message.$author.set({
  $name: signal("Joe"),
  age: 35,
});

In the watcher() function, we read both $author and $name. Therefore, every time we update either of them, the watcher() will be notified.

Incorrect: store a local reference to a watched signal without reading
const author = message.$author();

watcher(() => {
  console.log(author.$name());
});

message.$author().$name.set("Sara");

message.$author = signal({ $name: signal("Joe"), age: 30 });

The first change will be picked up, message.$author() and author are the same object, and the .$name property is read in the watcher().

However, the second change is not picked up, because the message.$author relation is not tracked by the watcher(). The watcher() is still using the “old” author.

🛑
Common pitfall: console.log
watcher(() => {
  console.log(message);
});

// Won't trigger a re-run.
message.updateTitle("Hello world");

In the above example, the updated message title won’t be printed because it is not read inside the watcher(). The watcher() only depends on the message variable, which is not a signal but a regular variable. In other words, $title is not read by the watcher().

Correct: updating the objects
watcher(() => {
  console.log(message.$likes().length);
});

message.$likes.mutate(likes => likes.push("Jennifer"));

message.$likes.update(likes => ([...likes, "Jennifer"]));

This will work as expected: in Angular Signals, calling the mutate() method will always result in an update notification (and will bypass the equality check).

If an Angular Signal contains an object, the new value will always be considered unequal to the previous value by default (unless you override the equality check function). As a result, the second line will also trigger an update notification.

Incorrect: mutating an object inside a signal
watcher(() => {
  console.log(message.$author().age);
});

message.$author().age = 23;

We’ve updated the field of an object, but we didn’t call any method that could cause a notification — the signal will not be aware of our actions, and will not compare values, will not emit notifications.

Incorrect: reading signals asynchronously
watcher(() => {
  setTimeout(() => {
    console.log(message.$likes().join(", "));
  }, 10);
});

message.$likes.mutate(likes => likes.push("Jennifer"));

In Angular Signals, the watching functions are unable to detect reactivity graph dependencies within asynchronous calls.

Conclusion

Using the experience accumulated by other frameworks and knowledge from our own experiments, we can fully unleash the power of automatic dependency tracking in Angular Signals without derailing the “reactivity train”.


💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.

🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.


🖼
Ivan Aivazovsky, “Ship in the Stormy Sea”, 1887