Angular signal - a new reactivity primitive.
Angular 17 has brought lots of new features. One of them is Signal. If you’re an Angular user, you surely know that zone.js as the underlying mechanism of change detection in Angular. It is simple, elegant, and sufficent in terms of performance in most of cases. The beauty of zone.js is that you can use any kind of data stucture to for state, you can use plain old javascript object, observable, now you have one more option, signal. To understand signal, I will review what we have. In this post, I will use the following application to demonstrate the different ways to represent state in Angular. The source code of the app is hosted at github
Phase 1 - The baseline application
The application has three component, the root component, which is the app component
, and two child components which is held inside the app component.
The application is run the production mode, the purpose is to remove the
detection of ExpressionChangedAfterItHasBeenCheckedError
, so that is closer to
the real case. The two child components have a button that can update the
the time on the screen, one button updates on synchrounously, another button updates
asyunchrounously.
The following is when the sync button is clicked.
The following is when the async button is clicked.
For the sync button, it updates the time first, then zone.js
only triggered
one change detection cycle
because of the click event handler. For async
button, zone.js
triggers one cycle
because of the click event handler, then
trigger another cycle
by the setTimeout callback. The change detection
cycle
start from the root component. This is because I use the default change
strategy, the cycle
will peform change detection for each component reursvely.
Phase 2 - Use OnPush change detection strategy.
The defualt change detection works very well until people find it can cause
performance issue because each change detection cycle can cause change detection
of thousands or tens of thousands of component. So to reduce number of
components of affected, we can change the change detection strategy to OnPush.
We call these components OnPush
components.
When the sync button is clicked, we found the following,
When the async button is clicked, we found the following.
We still trigger same amount of the change detection cycles for each click. But the components affected are reduced to components on the event hierachy, the components which is not on the event hierachy are not affected.
However, this cause a problem in the async button. The setTimeout callback,
still trigger a change detection cycle, which means the zone.js
is still working
fine. But the problem is that, cycle will not perform change detection for
the async component, which is a OnPush
component. The reason is that Angular
does not know that the async component does not know that it is dirty? I, as
the developer, have to tell angular that this button is dirty.
Phase 3 - ChangeDetectorRef to recure
Use the following code will mark the component to be dirty of time is changed in the setTimeout callback.
updateTime() {
this.time = new Date();
console.log(
'async time is updated to ',
this.date.transform(this.time, 'mediumTime')
);
this.cd.markForCheck();
}
Now the asyn button will generate the following output.
We can see that in the second cycle, change detection is peformed in the async button correctly.
This is good. But we put the responsibility of marking component dirty on the developer. In a sense, it is encapsulation leak. I as a developer have to know these ugly details angular change detection.
Phase 4 - Observable with AsyncPipe to mark dirty automatically.
To solve this encapsulation leak, Angular provide us AsyncPiple. The idea is
that, instead of using plain old javascript to hold state, we use observable. In
the template, we use AsyncPipe
to subscribe the change of the observable, and
mark the component dirty automatically, when observable emit. In this way, we
no longer need to deal with ChangeDetectorRef
directly.
private subject = new BehaviorSubject(new Date());
time$ = this.subject.asObservable();
updateTime() {
const time = new Date();
this.subject.next(time);
console.log(
'async time is updated to ',
this.date.transform(time, 'mediumTime')
);
}
update() {
setTimeout(() => this.updateTime(), 2000);
}
<p>
Time: {{ time$ | async | date : "mediumTime" }}
<button (click)="update()">Update</button>
</p>
Phase 5 - signal, the reactive primitive
The Observable with AsyncPipe is an elegant solution. The developers do not need to explictly tell Angular that a component is dirty. Signal is similar to Observable/Subject in that when state change, some code can be executed.
In the following, the effect
function can registered a section of code to be
run when the signal used in side code change.
this.timeSignal = signal(new Date());
//
effect(() => {
this.time = this.timeSignal();
console.log(
"async time is updated to ",
this.date.transform(this.time, "mediumTime")
);
cd.markForCheck();
});
updateTime() {
this.timeSignal.set(new Date());
}
update() {
setTimeout(() => this.updateTime(), 2000);
}
This difference is that the effect
function does not need to subscribe to
the change of signal explicitly. In the effect callback, I explicitly tell
angular the component is dirty. So in the next change detection cycle, angular
will perform change detection for the component.
Angular can simplify the code above, by binding the signal function directly to the template, and the template function can tell angular the component is dirty, like the following.
time = signal(new Date());
updateTime() {
this.time.set(new Date());
console.log(
'async time is updated to ',
this.date.transform(this.time(), 'mediumTime')
);
}
update() {
setTimeout(() => this.updateTime(), 2000);
}
<p>
Time: {{ time() | date : "mediumTime" }}
<button (click)="update()">Update</button>
</p>
Angular signal is a new reactivity primitive, the api is simple and easy to use, but I am not sure whether I should switch to use it as Observable still has lots feature that signal does have.