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']);
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.
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.
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.
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.
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
.
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()
.
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.
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.
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.
Subscribe to my newsletter
Read articles from Evgeniy OZ directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Evgeniy OZ
Evgeniy OZ
Angular & Rust developer