Local Change Detection in Angular

Angular 16 introduced Signals as a pivotal feature, setting the stage for future applications and laying the foundation for a zoneless environment. Signals operate reactively, enabling the generation of derived values or side effects through functions like signal(), computed(), and effect().

These Signals are instrumental in Angular’s shift from a component-centric rendering approach to one centered around Signals. The dependency graph created by Signals represents the application state. When this graph changes, Angular triggers a DOM update via Change Detection.

From a framework’s perspective, the render process is just a side effect of a Signal change. By reacting to the Signals, Angular knows exactly when and what to update.

To achieve that, we require a new type of component. With the new Signal Component, it is not zone.js that triggers the Change Detection but the Signals themselves.

Unfortunately, Signal Components are not available in version 17. So we have to wait a little bit longer.

The obvious question is: “What benefits do I get from Signals now?”. In 17, there is an answer: Local Change Detection.

If you prefer watching a video over reading, here’s something for you:

https://medium.com/media/c8a6b5d1c722cb7befabac063c277bfb/href

Poor Performing Change Detection

Currently, Angular is not really aware if a change has happened. So, it relies on zone.js and the Change Detection.

zone.js triggers the Change Detection whenever a DOM Event happens or an asynchronous task ends.

The Change Detection must go through the complete component tree and search for changes. If it detects one, it updates the affected DOM node.

This is not very performant because the Change Detection even runs when there is no change at all.

Consider a scenario with a parent and child component. The parent component displays a data grid, while the child component features a timer showing the seconds elapsed since the last update:

@Component({
selector: ‘app-list’,
template: `
<div>
<mat-table [dataSource]=”dataSource”>
<ng-container matColumnDef=”title”>
<mat-header-cell *matHeaderCellDef> Title</mat-header-cell>
<mat-cell *matCellDef=”let element”>{{ element.title }}</mat-cell>
</ng-container>
<ng-container matColumnDef=”description”>
<mat-header-cell *matHeaderCellDef> Country</mat-header-cell>
<mat-cell *matCellDef=”let element”>{{ element.description }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef=”displayedColumns”/>
<mat-row *matRowDef=”let row; columns: displayedColumns;”/>
</mat-table>
<div>
@if (lastUpdate) {
<app-timer [lastUpdate]=”lastUpdate”></app-timer>
}
<button mat-raised-button color=”primary” (click)=”refresh()”>Refresh</button>
</div>
</div>
{{logCd()}}
`,
standalone: true,
imports: [MatTableModule, MatButtonModule, TimerComponent]
})
export class ListComponent implements OnInit {
lastUpdate: Date | undefined
dataSource = new MatTableDataSource<Holiday[]>([]);
displayedColumns = [‘title’, ‘description’];
ngOnInit() {
this.refresh()
}
refresh() {
fetch(‘https://api.eternal-holidays.net/holiday’).then(res => res.json()).then(value => {
this.lastUpdate = new Date();
this.dataSource.data = value;
});
}
logCd() {
console.log(‘cd from list’);
}
}@Component({
selector: ‘app-timer’,
template: `<span>Last Updated: {{ lastUpdateInSeconds | number:’1.0-0′ }} Seconds</span> {{ logCd() }}`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe, DecimalPipe]
})
export class TimerComponent {
@Input() lastUpdate = new Date();
lastUpdateInSeconds = 0
constructor() {
setInterval(() => {
this.lastUpdateInSeconds = (new Date().getTime() – this.lastUpdate.getTime()) / 1_000;
}, 1000);
}
logCd() {
console.log(‘log from timer’);
}
}

The TimerComponent updates its lastUpdateInSeconds within an interval every second. That interval let’s zone.js trigger the Change Detection every second as well.

So the Change Detection starts, goes through the parent component and verifies if some data has changed. If yes, it would update the necessary DOM elements and then move downwards to its children, which is the TimerComponent.

That means that Angular unnecessarily checks the ListComponent every second.

The logCd() in both components will log whenever the check runs on them. At the moment, they log quite often.

OnPush

A popular setting for the Component decorator is ChangeDetectionStrategy:OnPush. When Angular runs the Change Detection, it stops whenever it encounters a component with that setting. It would also not traverse through its children unless that particular component has a flag that marks it as “dirty”.

Common criteria when a component becomes dirty are:

An Input@ changes its object referenceThe component runs an event handler. Just a click on an element without an event handler is not enough.An async pipe is in place, and the underlying Observable emits a new valueA Signal changes

It is important to note that the “dirty marking” does not trigger the Change Detection. That is still the task of zone.js, which schedules it asynchronously on an executed event handler or when an asynchronous task ends.

Once Angular marks a component as “dirty”, it also applies that to its parent components. Why is that necessary? With a parent being set to OnPush as well, the CD would never go to its children.

The following drawings show the difference between Change Detection with OnPush and Default strategy.

The drawing shows “Dirty Marking” as a separate process which runs before the Change Detection. This is only true as property binding is not involved. “Dirty Checking” would also happen during the Change Detection

That would mean that as long as the TimerComponent is a child of the ListComponent, the Change Detection needs to go through the ListComponent and check that one as well.

Let’s play

Set OnPush only on the TimerComponent. You will see that the log only happens in the ListComponent. Why’s that? Well, because OnPush doesn’t disable zone.js for components. It is still aware of the interval and triggers the Change Detection, which runs every second.

The TimerComponent is not marked as dirty because none of the criteria (see list above) fit. As a result, the DOM doesn’t update. An asynchronous task only triggers the Change Detection but does not mark the component as dirty.

If you click on refresh, you will see that it also triggers the Change Detection on the TimerComponent. That’s because its @Input is updated with a new reference.

If you click on the text “Updated”, you will see that nothing happens. You raised a DOM event, but no event handler exists that would internally mark it as dirty.

Let’s add an event listener to the text. It doesn’t have to do anything. It is enough that it exists. You will see that the Change Detection goes off as soon as you click on it.

Since we are already checking the list, let’s also do the async pipe:

@Component({
selector: ‘app-timer’,
template: `<span class=”px-2″>Last Updated: {{ lastUpdateInSeconds$ | async | number:’1.0-0′ }}
Seconds</span> {{ logCd() }}`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DatePipe,
DecimalPipe,
AsyncPipe
]
})
export class TimerComponent {
@Input() lastUpdate = new Date();

lastUpdateInSeconds$ = interval(1000).pipe(map(() => this.lastUpdateInSeconds = (new Date().getTime() – this.lastUpdate.getTime()) / 1_000))

logCd() {
console.log(‘log from timer’);
}
}

The timer should now update every second. If you are motivated, you can also try out a subscription in the component. You will see that the Change Detection will not check the component anymore.

We already use OnPush , and the Change Detection still checks the ListComponent every second. That’s not very efficient.

Local Change Detection

Enter Angular 17 and Signals.

One week before the Angular team released version 17, they threw in local Change Detection, which is a perfect fit for our use case.

It allows us to mark a single component in the component tree as dirty. The Change Detection will, therefore, not check its parents. If its child components are marked as OnPush, then they will also opt-out.

To make this work, we need two ingredients: Signals and OnPush. And that’s it already.

The drawing below shows this new feature:

We add OnPush and refactor the TimerComponent to Signals:

@Component({
selector: ‘app-timer’,
template: `<span>Last Updated: {{ lastUpdateInSeconds() | number:’1.0-0′ }} Seconds</span> {{ logCd() }}`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe, DecimalPipe, AsyncPipe]
})
export class TimerComponent {
@Input() lastUpdate = new Date();
lastUpdateInSeconds = signal(0)
constructor() {
setInterval(() => {
this.lastUpdateInSeconds.set((new Date().getTime() – this.lastUpdate.getTime()) / 1_000);
}, 1000);
}

logCd() {
console.log(‘log from timer’);
}
}

The ListComponent also has to be OnPush. Otherwise, the Change Detection would always check it.

If you now reload the page, you will see that the timer is working, but the list had a check only once. If you now click on the button “Refresh”, you are triggering a handled DOM event in the ListComponent. Therefore, the Change Detection runs for that component only…twice.

Why twice and not once? Well, we have here two triggers. The first one is the DOM event, and the second one is the asynchronous task from the fetch that ends a little bit later.

Et voilà, that’s local Change Detection. Just a glimpse of what we can expect for Signal Components.

Summary

Local Change Detection is a powerful feature. We can precisely define which components should undergo a check in the Change Detection.

It is available only in Angular 17, and we must use both OnPush and Signals.

We can see this is a glimpse of the upcoming Signal Components, which will allow an even more fine-grained Change Detection.

The demo repository is available at

GitHub – rainerhahnekamp/angular-local-change-detection: Example demonstrating local change detection in Angular

Acknowledgements

I want to express my gratitude to Thomas Laforge for his thorough review of this article and his insistence on improving the illustrations.

Additionally, I extend my thanks to Andrew Scott and Sander Elias for their patience in providing insights into the inner workings of Change Detection.

Further Reading

On the road to Fine-Grained Change Detectionperf(core): Update LView consumer to only mark component for check by atscott · Pull Request #52302 · angular/angularThe state of Change Detection in Angular v17Deep dive into the OnPush change detection strategy in Angular

Local Change Detection in Angular was originally published in ngconf on Medium, where people are continuing the conversation by highlighting and responding to this story.

Leave a Comment

Your email address will not be published. Required fields are marked *