Change Detection and Zone.js in Angular: OnPush, ChangeDetectorRef and NgZone
How does Angular update the screen? The events Zone.js catches, change detection across the component tree, the OnPush strategy, immutable data, ChangeDetectorRef and NgZone.runOutsideAngular.
Version note: The code and commands in this post are for Angular 12.
Who Updates the Screen?
Since the start of the series we have been doing the same thing: change a property in the class, and the screen updates by itself. We never once said "refresh the screen now." So how does Angular know a value has changed? Is someone standing guard over every variable?
Not really. Angular doesn't watch variables one by one. Instead, at moments when "something might have happened," it checks the whole screen from the top. That checking is called change detection. The informant who reports those "something might have happened" moments is Zone.js. In this post we will get to know both up close, and then look at how to put less load on the mechanism.
Zone.js: The Doorman Who Hears Every Door
Open src/polyfills.ts in the project. Near the bottom you will find this line:
import 'zone.js'; // Included with Angular CLI.
Before the application starts, Zone.js wraps the browser's asynchronous APIs: addEventListener, setTimeout, setInterval, XMLHttpRequest, Promise and the like. From then on, all of them run by passing through Zone.js. Think of a building's doorman: whoever comes in, the doorman sees it and tells the management "someone's here." When Angular gets that message, it starts change detection.
Let's see it in an example:
@Component({
selector: 'app-countdown',
template: `<p>Time left: {{ seconds }}</p>`
})
export class CountdownComponent implements OnInit {
seconds = 10;
ngOnInit(): void {
setInterval(() => {
if (this.seconds > 0) {
this.seconds--;
}
}, 1000);
}
}
We tell Angular nothing here; we just call a plain setInterval. Still, the number on the screen goes down every second. When the setInterval callback finishes, Zone.js notifies Angular, and Angular re-checks the templates and renders the changed seconds value. Exactly the same thing happens for click events and HttpClient requests. We didn't clear the interval in this example; in real code, don't forget to call clearInterval in the ngOnDestroy we saw earlier.
From the Top of the Tree Down
So what does Angular do when the message arrives? Remember the component tree we saw earlier. By default, Angular starts at AppComponent at the top and walks the tree from top to bottom. It re-evaluates the expressions in each component's template, compares them with the previous values, and if something differs, it updates only that part of the DOM.
To see this with your own eyes, add a method that the template calls:
@Component({
selector: 'app-product-list',
template: `
{{ logCheck() }}
<app-product-card *ngFor="let p of products" [product]="p"></app-product-card>
`
})
export class ProductListComponent {
products: Product[] = [
{ id: 1, name: 'Mechanical Keyboard', price: 1450 },
{ id: 2, name: 'Wireless Mouse', price: 600 }
];
logCheck(): string {
console.log('ProductListComponent checked');
return '';
}
}
Product here is a simple interface with id, name and price fields. Now click anywhere on the page, type into an input, or let the countdown from above run in another corner of the screen. You will see that line show up in the console again and again. Even an event that has nothing to do with this component triggers a check of the whole tree. Seeing the logs twice in development mode is normal too: after each pass, Angular runs a second verification pass to make sure the values stayed stable.
Angular does this check very quickly, and in most applications the default behavior never causes a problem. But once tables with hundreds of rows, frequently updated charts, or data arriving every second come into play, the "check everything every time" strategy starts to get expensive.
OnPush: No Reason, No Check
You can tell Angular "don't check this component on every pass, only look when there is a real reason":
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-product-card',
template: `
<h3>{{ product.name }}</h3>
<p>{{ product.price }} TL</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
@Input() product!: Product;
}
The only difference is the changeDetection: ChangeDetectionStrategy.OnPush line in the decorator. The default value is ChangeDetectionStrategy.Default, and we never write it out. When a change detection pass reaches a component marked OnPush, Angular asks whether it deserves a check; if there is no reason, that component and its entire branch are skipped.
Think of elevator maintenance in a building. With the Default strategy, the technician visits every floor every day. With OnPush, the technician only goes to floors where a fault report was filed. The workload drops dramatically, but the fault report has to be filed correctly. Otherwise a broken floor slips through.
What Triggers OnPush?
An OnPush component is checked in these cases:
First, when the reference of an @Input() value changes. If the parent passes a new object or a new array, Angular notices.
Second, when a DOM event is caught in the component's own template or in its children. Clicking a button inside the card puts that card on the check list.
Third, when an async pipe in the template receives a new value. The async pipe we saw earlier marks the component as "needs checking" behind the scenes.
Fourth, when you ask for it manually. We will see that with ChangeDetectorRef in a moment.
What is missing from the list matters too. If you change a property with setTimeout inside an OnPush component, a change detection pass still runs, but this component is skipped and the screen doesn't update. The same thing usually happens when you manually assign a value coming from a service to a property inside subscribe.
Immutable Data: Don't Change the Array, Hand Over a New One
This is the most common OnPush trap:
// ProductListComponent (parent)
addProduct(): void {
// Wrong: same array reference, an OnPush child will not notice
this.products.push({ id: 3, name: 'Monitor', price: 4200 });
// Right: a new array, the reference changes
this.products = [...this.products, { id: 3, name: 'Monitor', price: 4200 }];
}
When Angular compares inputs, it doesn't look inside the object, only at the reference; in other words, it asks "is this still the same box?" With push you put something new in the box, but the box stays the same. An OnPush child component that receives the array through an @Input() won't see the change. When you create a new array with the spread operator, you are holding a new box, and the child notices.
The same rule applies to objects: this.product = { ...this.product, price: 999 } instead of this.product.price = 999. The habit of producing a new copy instead of changing data in place is called immutable data. Combined with OnPush, it improves performance and makes it easier to track where changes happen.
ChangeDetectorRef: markForCheck and detectChanges
Sometimes a change comes in through a path that isn't on the list, and you need to alert Angular yourself. For that, you inject ChangeDetectorRef into the component:
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
@Component({
selector: 'app-cart-badge',
template: `<span class="badge">{{ count }}</span>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartBadgeComponent implements OnInit {
count = 0;
constructor(private cartService: CartService, private cdr: ChangeDetectorRef) { }
ngOnInit(): void {
this.cartService.count$.subscribe(count => {
this.count = count;
this.cdr.markForCheck();
});
}
}
markForCheck() marks this component and all of its ancestors up to the root as "check on the next pass." It doesn't run anything right away; it just files the fault report. detectChanges() doesn't wait: it runs change detection for this component and its branch right then, synchronously.
The general rule: in most cases markForCheck() is enough and safer. Save detectChanges() for special cases where you are outside Angular's normal cycle and need to see the result on screen immediately. There is also a cleaner way in this example: if you use count$ directly in the template with the async pipe, both markForCheck and the hassle of closing the subscription go away.
NgZone.runOutsideAngular: Don't Tell the Doorman
Zone.js reporting every event is sometimes too much. When the page scrolls, dozens of scroll events arrive per second, and each one triggers a check of the whole tree. Yet all we care about is showing a "Back to top" button once the user passes 300 pixels.
import { Component, NgZone, OnDestroy, OnInit } from '@angular/core';
@Component({
selector: 'app-scroll-top',
template: `<button *ngIf="visible" (click)="scrollTop()">Back to top</button>`
})
export class ScrollTopComponent implements OnInit, OnDestroy {
visible = false;
constructor(private ngZone: NgZone) { }
ngOnInit(): void {
this.ngZone.runOutsideAngular(() => {
window.addEventListener('scroll', this.onScroll);
});
}
ngOnDestroy(): void {
window.removeEventListener('scroll', this.onScroll);
}
scrollTop(): void {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
private onScroll = (): void => {
const shouldShow = window.scrollY > 300;
if (shouldShow !== this.visible) {
this.ngZone.run(() => (this.visible = shouldShow));
}
};
}
An event listener registered inside runOutsideAngular runs outside Angular's zone. The doorman doesn't see who comes through that door and doesn't tell the management. No matter how much the user scrolls, change detection isn't triggered. At the moment the button's visibility really needs to change, we step back into Angular's world with ngZone.run and a single check pass runs.
We define onScroll as an arrow function for two reasons: so that this points to the right component, and so that we can pass the same function reference to removeEventListener. The same technique is used for animations, frequently fired mousemove events, and third-party charting libraries. We will see in the version posts how later Angular releases loosened their dependency on Zone.js.
A Small Experiment
Write a list component with five or six products and show each product with its own card component. Add a logCheck() to the card template like the one above, and put a setInterval in the parent that increments a counter every second. Watch the console show the cards being checked every second.
Then make the card OnPush and watch the console again. Next, add an "Update prices" button to the parent. First change a product's price in place with this.products[0].price = 999 and see that the screen doesn't update. Then do the same with a new object and a new array, and note the difference.
Instead of trying out behavior like this by hand every time, there is a way to check automatically that your code works. Next up is testing in Angular: testing components and services with Karma, Jasmine and TestBed.