Change Detection and Signals
One of the great things about signals is that it will reduce a great deal of the need for us to understand precisely how change detection works in Angular.
Maybe you are starting to notice a theme in this module — the older concepts that we are covering require a great deal of explanation to try and understand the mental model and how everything fits together. The newer alternatives are usually much easier to explain and are generally just more intuitive.
Before signals, there was a lot of benefit in understanding and, to an extent, “gaming” or “optimising” your application to better suit the change detection system and improve performance (or to understand why something changed but you aren’t seeing it render on screen).
I think it will still be important to understand how change detection works with signals, but it won’t be as important as it was before. There will also be much less of these strategies for improving change detection performance.
Unfortunately, although we already have signals available in Angular, the
change detection mechanisms that make full use of them are not yet stable. This
will likely be coming very soon. Our strategy throughout this course will
basically be to just use signals and approach change detection as if the new
change detection mechanisms have already been implemented. Then, when they
become available, there won’t be much if anything you will need to do to start
utilising the benefits of this approach. In the mean time, change detection will
work just fine with signals in our applications, the only downside is that —
until the change detection update is released — it will be slightly less
performant not using the OnPush change detection strategy.
We will not be implementing the old style change detection techniques (like
using OnPush), but as usual we will still cover the concepts as you will
likely run into Angular applications that are not using these newer approaches.
The rest of this lesson will be dedicated to understanding the old style of
change detection. I am using the term “old” loosely here, because most well
architected applications today use OnPush. Unless you are exclusing working
with new Angular application code, it is still a concept you will need to
understand for a while.
As you will see, we are about to discuss a lot of ideas and nuances that need to
be kept in mind when utilising the old OnPush change detection strategy for
optimising change detection. Whilst there is still plenty of depth in signals
change detection to uncover to deepen your knowledge of how Angular works under
the hood, the high level story of how change detection with signals works is
quite simple: if a signal is updated, the signal will handle notifying Angular
that a change has occurred, and Angular can update the specific thing that needs
updating.
As you’re about to see — this is a refreshingly simple idea. The “downside” is that we will need to use signals for any changes we want reflected in the template using this paradigm. With the old method, as complicated and ineffecient as it is, it did allow for basically any change to be reflected no matter how it was made.
Personally, I don’t see being forced to use signals as a downside as they are fantastically powerful and rather simple to use. I am going to be using signals anyway, so the change detection improvements basically come for free.
Change Detection: OnPush vs Default
To get right to the point: Angular needs to keep track of when things in your application change. Let’s say we have a class member in the class for my component:
@Component({ selector: 'app-home', template: ` <p>{{ name }}</p> <button (click)="changeName()">Change name</button> `,})export class HomeComponent { name = 'Josh';
changeName() { this.name = 'Kathy'; }}The value for name is initially Josh and we are displaying that in the
template. But, we have a button that triggers the changeName method when
clicked which will change this value to Kathy. I encourage you to run this
example for yourself, because we are going to play with this a bit.
If you click the button, you might be unsurprised to see that the value displayed in the template changes. This is what we would want to happen, but the underlying mechanisms for Angular to achieve this seemingly simple task aren’t so simple.
We could cause a change to the template in a different way — what about
a setTimeout that triggers the change after 2 seconds?
@Component({ selector: 'app-home', template: ` <p>{{ name }}</p> `,})export class HomeComponent implements OnInit { name = 'Josh';
ngOnInit() { setTimeout(() => (this.name = 'Kathy'), 2000); }}Or, maybe we have a setInterval changing the value every second:
@Component({ selector: 'app-home', template: ` <p>{{ value }}</p> `,})export class HomeComponent implements OnInit { value = 1;
ngOnInit() { setInterval(() => this.value++, 1000); }}If you run these examples, you will see that in every case the value is updated correctly. Angular detects when these changes occur and updates the template.
How does Angular detect changes?
We aren’t going to get into the precise underlying mechanisms that allow Angular to detect and respond to changes. This can become useful for performance optimisation, but it isn’t something we need to get into right now.
Our main goal in this lesson will be to understand the difference between the
Default change detection strategy, and the OnPush change detection strategy.
To do that, we need to understand a little bit about how the default change
detection strategy works.
The key idea behind how Angular is able to achieve this is that it uses
something called Zone.js to detect when any code runs that has the potential
to cause a change. The scenarios that might cause a change to your application’s
state include:
- Component initialisation
- Events being triggered (like our button click from before)
- Handling the response of an HTTP request
- MacroTasks such as
setTimeout()andsetInterval() - MicroTasks such as handling Promises
But there are also some other scenarios that could cause a change. Since Angular knows when something has happened that might cause a change, it can check your application to see if anything has changed and render the update if necessary.
With the Default change detection strategy, Angular will check every
component in the component tree. If one of these scenarios that might cause
a change occurs, then Angular will check all of these components to see if the
data/model they depend upon has changed. In our examples, we were only changing
the HomeComponent so the HomeComponent is the only one that needs to be
updated, but Angular still needs to check every single component regardless.
This might sound bad, and it’s certainly not optimal, but using the Default
strategy is generally fine in terms of performance. Angular can perform these
checks quite quickly. Using OnPush change detection instead will still improve
performance, but as we will discuss later, it isn’t the main reason we should
use it (or at least I don’t think so).
Using OnPush Change Detection
Now let’s see how the OnPush change detection strategy differs from the
default. We will revisit all of our examples from before, but this time we will
update the component to use OnPush change detection:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({ selector: 'app-home', template: ` <p>{{ name }}</p> <button (click)="changeName()">Change name</button> `, changeDetection: ChangeDetectionStrategy.OnPush,})export class HomeComponent { name = 'Josh';
changeName() { this.name = 'Kathy'; }}import { ChangeDetectionStrategy, Component, OnInit,} from '@angular/core';
@Component({ selector: 'app-home', template: ` <p>{{ name }}</p> `, changeDetection: ChangeDetectionStrategy.OnPush,})export class HomeComponent implements OnInit { name = 'Josh';
ngOnInit() { setTimeout(() => (this.name = 'Kathy'), 2000); }}import { ChangeDetectionStrategy, Component, OnInit,} from '@angular/core';
@Component({ selector: 'app-home', template: ` <p>{{ value }}</p> `, changeDetection: ChangeDetectionStrategy.OnPush,})export class HomeComponent implements OnInit { value = 'Josh';
ngOnInit() { setInterval(() => this.value++, 1000); }}Now you will see that only the example where we are responding to a button click works, all the rest fail to update the template.
The reason for this is that the OnPush change detection strategy adds some
rules around what can can cause a component to be checked for changes during
change detection. With the Default strategy, Angular needs to respond to any
possible change, with OnPush we are sort of able to tell Angular not to worry
about checking this component for changes except for in more specific
circumstances. This means Angular is going to have a lot less checking work to
do.
The ways in which a component using the OnPush change detection strategy can
be marked to be checked during change detection are:
- A component’s event handler is triggered inside of the component, e.g:
<button (click)="changeName()">Change name</button>When (click) or any other event bindings are triggered it will trigger change
detection. We have seen this already as it’s our one example that still works.
- A component’s inputs have changed, e.g:
<some-component [someInput]="someValue"></some-component>If the value of someValue changes it will trigger change detection for the
component and all of the component’s ancestors up to the root component.
- The
asyncpipe is used in the template and the stream/promise it is being used on emits a value:
<some-component [someInput]="someStream$ | async"></some-component>If we don’t satisfy any of the three conditions above, then changes will not be
rendered for our component. It is also possible to manually inject the
ChangeDetectorRef and trigger change detection yourself, but this is generally
best avoided and should only be treated as a last resort.
This is much more restrictive, but it will increase performance and it does give us an even greater benefit.