Angular Change Detection

Angular 9 — Change Detection with Pipe and OnPush Strategy

Turning Change Detection with Pipe and OnPush Strategy

Quang Trong VU
13 min readOct 5, 2020

I. Relationship with Pipe
II. A Comprehensive Guide to Angular onPush Change Detection Strategy

I. Relationship with Pipe

1. How Change Detection Work ?

Normally, Angular looks for changes to data-bound values in a change detection process that runs after every DOM event: every keystroke, mouse move, timer tick, and server response.

You can use Pipe to change the Angular the default change detection strategy.

flying-heroes.component.html (v1)

New hero:   
<input type="text" #box
(keyup.enter)="addHero(box.value); box.value=''"
placeholder="hero name">
<button (click)="reset()">Reset</button>
<div *ngFor="let hero of heroes">{{hero.name}}</div>

flying-heroes.component.ts (v1)

export class FlyingHeroesComponent {
heroes: any[] = [];
canFly = true;
constructor() { this.reset(); }
addHero(name: string) {
name = name.trim();
if (!name) { return; }
let hero = {name, canFly: this.canFly};
this.heroes.push(hero);
}
reset() { this.heroes = HEROES.slice(); }
}

Angular updates the display every time the user adds a hero. If the user clicks the Reset button, Angular replaces heroes with a new array of the original heroes and updates the display. If you add the ability to remove or change a hero, Angular would detect those changes and update the display as well.

However, executing a Pipe to update the display with every change would slow down your app’s performance. So Angular uses a faster change-detection algorithm for executing a pipe, as described in the next section.

2. Detecting pure changes to primitives and object references

By default, pipes are defined as pure so that Angular executes the pipe only when it detects a pure change to the input value. A pure change is either a change to a primitive input value (such as String, Number, Boolean, or Symbol), or a changed object reference (such as Date, Array, Function, or Object).

A pure pipe must use a pure function, which is one that processes inputs and returns values without side effects. In other words, given the same input, a pure function should always return the same output.

With a pure pipe, Angular ignores changes within composite objects, such as a newly added element of an existing array, because checking a primitive value or object reference is much faster than performing a deep check for differences within objects. Angular can quickly determine if it can skip executing the pipe and updating the view.

However, a pure pipe with an array as input may not work the way you want. To demonstrate this issue, change the previous example to filter the list of heroes to just those heroes who can fly. Use the FlyingHeroesPipe in the *ngFor repeater as shown in the following code. The tabs for the example show the following:

  • The template (flying-heroes.component.html (flyers)) with the new pipe.
  • The FlyingHeroesPipe custom pipe implementation (flying-heroes.pipe.ts).

flying-heroes.component.html (flyers)

<div *ngFor="let hero of (heroes | flyingHeroes)">{{hero.name}}</div>

flying-heroes.pipe.ts

import { Pipe, PipeTransform } from '@angular/core';import { Flyer } from './heroes';@Pipe({ name: 'flyingHeroes' })
export class FlyingHeroesPipe implements PipeTransform {
transform(allHeroes: Flyer[]) {
return allHeroes.filter(hero => hero.canFly);
}
}

The app now shows unexpected behavior: When the user adds flying heroes, none of them appear under “Heroes who fly.” This happens because the code that adds a hero does so by pushing it onto the heroes array:

flying-heroes.component.ts

this.heroes.push(hero);

The change detector ignores changes to elements of an array, so the pipe doesn’t run.

The reason Angular ignores the changed array element is that the reference to the array hasn’t changed. Since the array is the same, Angular does not update the display.

One way to get the behavior you want is to change the object reference itself. You can replace the array with a new array containing the newly changed elements, and then input the new array to the pipe. In the above example, you can create an array with the new hero appended, and assign that to heroes. Angular detects the change in the array reference and executes the pipe.

To summarize, if you mutate(eg: push, pop) the input array, the pure pipe doesn’t execute. If you replace the input array, the pipe executes and the display is updated.

To keep your component simpler and independent of HTML templates that use pipes, you can, as an alternative, use an impure pipe to detect changes within composite objects such as arrays, as described in the next section.

3. Detecting impure changes within composite objects

To execute a custom pipe after a change within a composite object, such as a change to an element of an array, you need to define your pipe as impure to detect impure changes. Angular executes an impure pipe every time it detects a change with every keystroke or mouse movement.

While an impure pipe can be useful, be careful using one. A long-running impure pipe could dramatically slow down your app.

Make a pipe impure by setting its pure flag to false:

flying-heroes.pipe.ts

@Pipe({
name: 'flyingHeroesImpure',
pure: false
})

The following code shows the complete implementation of FlyingHeroesImpurePipe, which extends FlyingHeroesPipe to inherit its characteristics. The example shows that you don't have to change anything else—the only difference is setting the pure flag as false in the pipe metadata.

flying-heroes.pipe.ts (FlyingHeroesImpurePipe)

@Pipe({
name: 'flyingHeroesImpure',
pure: false
})
export class FlyingHeroesImpurePipe extends FlyingHeroesPipe {}

flying-heroes.pipe.ts (FlyingHeroesPipe)

import { Pipe, PipeTransform } from '@angular/core';import { Flyer } from './heroes';@Pipe({ name: 'flyingHeroes' })
export class FlyingHeroesPipe implements PipeTransform {
transform(allHeroes: Flyer[]) {
return allHeroes.filter(hero => hero.canFly);
}
}

FlyingHeroesImpurePipe is a good candidate for an impure pipe because the transform function is trivial and fast:

flying-heroes.pipe.ts (filter)

return allHeroes.filter(hero => hero.canFly);

You can derive a FlyingHeroesImpureComponent from FlyingHeroesComponent. As shown in the code below, only the pipe in the template changes.

flying-heroes-impure.component.html (excerpt)

<div *ngFor="let hero of (heroes | flyingHeroesImpure)">
{{hero.name}}
</div>

II. A Comprehensive Guide to Angular onPush Change Detection Strategy

In order to know whether the view should be updated, Angular needs to access the new value, compare it with the old one, and make the decision on whether the view should be updated.

Now, imagine a big application with thousands of expressions; If we let Angular check every single one of them when a change detection cycle runs, we might encounter a performance problem.

1. OnPush Change Detection Strategy (not the default of Angular)

This tells Angular that the component only depends on its @inputs() ( aka pure ) and needs to be checked only in the following cases:

1-1 The Input reference changes.

By setting the onPush change detection strategy we are signing a contract with Angular that obliges us to work with immutable objects (or observables as we’ll see later).

The advantage of working with immutability in the context of change detection is that Angular could perform a simple reference check in order to know if the view should be checked. Such checks are way cheaper than a deep comparison check.

on-push-2.ts

@Component({
selector: 'tooltip',
template: `
<h1>{{config.position}}</h1>
{{runChangeDetection}}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TooltipComponent {

@Input() config;

get runChangeDetection() {
console.log('Checking the view');
return true;
}
}

on-push-3.ts

@Component({
template: `
<tooltip [config]="config"></tooltip>
`
})
export class AppComponent {
config = {
position: 'top'
};

onClick() {
this.config.position = 'bottom';
}
}

When we click on the button we will not see any log. That’s because Angular is comparing the old value with the new value by reference, something like:

/** Returns false in our case */
if( oldValue !== newValue )
{ runChangeDetection();}

Just a reminder that numbers, booleans, strings, null and undefined are primitive types. All primitive types are passed by value. Objects, arrays, and functions are also passed by value, but the value is a copy of a reference.

So in order to trigger a change detection in our component, we need to change the object reference.

@Component({
template: `
<tooltip [config]="config"></tooltip>
`
})
export class AppComponent {
config = {
position: 'top'
};

onClick() {
this.config = {
position: 'bottom'
}
}
}

With this change we will see that the view has been checked and the new value is displayed as expected.

1–2 An event originated from the component or one of its children.

A component could have an internal state that’s updated when an event is triggered from the component or one of his children.

For example:

@Component({
template: `
<button (click)="add()">Add</button>
{{count}}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = 0;

add() {
this.count++;
}

}

When we click on the button, Angular runs a change detection cycle and the view is updated as expected.

You might be thinking to yourself that this should work with every asynchronous API that triggers change detection, as we learned at the beginning, but it won’t. (!!!)

It turns out that the rule applies only to DOM events, so the following APIs will not work. (recall it’s for OnPush detection strategy!)

@Component({
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = 0;

constructor() {
setTimeout(() => this.count = 5, 0);

setInterval(() => this.count = 5, 100);

Promise.resolve().then(() => this.count = 5);

this.http.get('https://count.com').subscribe(res => {
this.count = res;
});
}

add() {
this.count++;
}

}

Note that you are still updating the property so in the next change detection cycle, for example, when we click on the button, the value will be six ( 5 + 1 ).

1–3 We run change detection explicitly.

Angular provides us with three methods for triggering change detection ourselves when needed.

The first is detectChanges() which tells Angular to run change detection on the component and his children.

@Component({
selector: 'counter',
template: `{{count}}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = 0;

constructor(private cdr: ChangeDetectorRef) {

setTimeout(() => {
this.count = 5;
this.cdr.detectChanges();
}, 1000);

}

}

The second is ApplicationRef.tick() which tells Angular to run change detection for the whole application.

tick() {

try {
this._views.forEach((view) => view.detectChanges());
...
} catch (e) {
...
}
}

The third is markForCheck() which does NOT trigger change detection. Instead, it marks all onPush ancestors as to be checked once, either as part of the current or next change detection cycle.

markForCheck(): void { 
markParentViewsForCheck(this._view);
}

export function markParentViewsForCheck(view: ViewData) {
let currView: ViewData|null = view;
while (currView) {
if (currView.def.flags & ViewFlags.OnPush) {
currView.state |= ViewState.ChecksEnabled;
}
currView = currView.viewContainerParent || currView.parent;
}
}

Another important thing to note here is that running change detection manually is not considered a “hack”, this is by design and it’s completely valid behavior (in reasonable cases, of course).

2. Angular Async Pipe

The async pipe subscribes to an observable or promise and returns the latest value it has emitted.

Let’s see a trivial example of an onPush component with an input() observable.

ListComponent

@Component({
template: `
<div *ngFor="let item of items">{{item.title}}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent implements OnInit {
@Input() items: Observable<Item>;
_items: Item[];

ngOnInit() {
this.items.subscribe(items => {
this._items = items; // 2. Update data-bound prop but not by DOM event => no trigger a change detection
});
}
}

AppComponent

@Component({
template: `
<button (click)="add()">Add</button>
<app-list [items$]="items$"></app-list>
`
})
export class AppComponent {
items = [];
items$ = new BehaviorSubject(this.items);

add() {
this.items.push({ title: Math.random() })
this.items$.next(this.items);
}
}

When we click on the button we are not going to see the view updated. This is because none of the conditions (1. @input with immutable, 2. DOM event update data-bound prop of the component and children, 3. call ChangeDetector explicitly) mentioned above occurred, so Angular will not check the component at the current change detection cycle.

Now, let’s change it to use the async pipe. (AsyncPipe is built-in impure pipe of Angular — ref: https://angular.io/guide/pipes)

@Component({
template: `
<div *ngFor="let item of items | async">{{item.title}}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent implements OnInit {
@Input() items;
}

Now we can see that the view is updated when we click on the button. The reason for that is that when a new value is emitted, the async pipe marks the component to be checked for changes. We can see it in the source code:

private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
this._ref.markForCheck();
}
}

Angular is calling to markForCheck() for us and that’s why the view is updated even though the reference hasn’t changed.

If a component depends only on its input properties, and they are observable, then this component can change if and only if one of its input properties emits an event(?).

Quick tip: It’s an anti-pattern to expose your subject to the outside world, always expose the observable, by using the asObservable() method

3. onPush and View Queries

Let’s say we have the following components:

TabsComponent

@Component({
selector: 'app-tabs',
template: `<ng-content></ng-content>`
})
export class TabsComponent implements OnInit {
@ContentChild(TabComponent) tab: TabComponent;

ngAfterContentInit() {
setTimeout(() => {
this.tab.content = 'Content';
}, 3000);
}
}

TabComponent

@Component({
selector: 'app-tab',
template: `{{content}}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TabComponent {
@Input() content;
}

Home

<app-tabs>
<app-tab></app-tab>
</app-tabs>

Probably your expectation is that after three seconds Angular will update the tab component view with the new content.

After all, we saw that if we update the input reference in onPush components this should trigger change detection, no? [Conflict with option 1. change @input reference? And here the explanation:]

Unfortunately, in this case, it doesn’t work that way. There is no way for Angular to know that we are updating a property in the tab component. Defining inputs() in the template is the only way to let Angular knows that this property should be checked on a change detection cycle.

<app-tabs>
<app-tab [content]="content"></app-tab>
</app-tabs>

Because we define explicitly the input() in the template, Angular creates a function called an updateRenderer(), that keeps track of the content value during each change detection cycle.

The simple solution in these cases is to use setters and call markForCheck().

@Component({
selector: 'app-tab',
template: `
{{_content}}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TabComponent {
_content;

@Input() set content(value) {
this._content = value;
this.cdr.markForCheck();
}

constructor(private cdr: ChangeDetectorRef) {}

}

4. onPush

After we understood the power of onPush, we can leverage it in order to create a more performant application. The more onPush components we have the less checks Angular needs to perform. Let’s see a real world example:

Let’s say that we have a todos component that takes a todos as input().

TodosComponent

@Component({
selector: 'app-todos',
template: `
<div *ngFor="let todo of todos">
{{todo.title}} - {{runChangeDetection}}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosComponent {
@Input() todos;

get runChangeDetection() {
console.log('TodosComponent - Checking the view');
return true;
}

}

AppComponent

@Component({
template: `
<button (click)="add()">Add</button>
<app-todos [todos]="todos"></app-todos>
`
})
export class AppComponent {
todos = [{ title: 'One' }, { title: 'Two' }];

add() {
this.todos = [...this.todos, { title: 'Three' }];
}
}

The disadvantage of the above approach is that when we click on the add button Angular needs to check each todo, even if nothing has changed, so in the first click we’ll see three logs in the console.

In the above example there is only one expression to check, but imagine a real world component with multiple bindings (ngIf, ngClass, expressions, etc.). This could get expensive.

We’re running change detection for no reason

The more performant way is to create a todo component and define its change detection strategy to be onPush. For example:

@Component({
selector: 'app-todos',
template: `
<app-todo [todo]="todo" *ngFor="let todo of todos"></app-todo>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosComponent {
@Input() todos;
}

@Component({
selector: 'app-todo',
template: `{{todo.title}} {{runChangeDetection}}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent {
@Input() todo;

get runChangeDetection() {
console.log('TodoComponent - Checking the view');
return true;
}

}

Now when we click the add button we’ll see a single log in the console because none of the inputs of the other todo components changed, therefore their view wasn’t checked.

Also, by creating a dedicated component we make our code more readable and reusable.

And you can run code without Change Detection

It is possible to run certain code blocks outside NgZone so that it does not trigger change detection.

References

https://www.angulararchitects.io/aktuelles/angular-elements-part-iii/

--

--

Quang Trong VU

Software Developer — Life’s a journey — Studying and Sharing