Angular Signals: Keeping the Reactivity Train

🪄 OZ 🎩
4 min readAug 19, 2023

--

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

In this article, let’s apply examples from a 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 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”.

--

--