Optimizing Change Detection in Angular 2+ By Example
One of the things Angular boasts is having automatic change detection by default, meaning that when an object or object property is changed at runtime, the change will be reflected in the view without having to explicity set up any manual mechanism for doing so. In version 2 and above, Angular has attempted to empower developers with more control over how and when to check for changes. I demonstrate this in an example from a class I taught online, which I would like to share, in hope that it will give you more of a concrete understanding of this idea.
Change detection in Angular 1
First, let's see how Angular 1 accomplished the same thing. For each expression in the app, a watcher would be assigned when registered on the $scope
, then each time a change occured, Angular would run through the list of every watcher to make sure the value returned from the expression has not changed. This was described as a digest cycle and for each change found in the list, another digest cycle would run until it successful pass through the list yielding no changes. This approach has a few downsides, however:
- If an expression has a changing value each digest cycle, for example, if you accidentally created a new object each time the expression was evaluated, you would eventually receive this all too familiar error:
10 $digest() iterations reached. Aborting!
Angular would throw an error in, after 10 digest cycles, to prevent an infinite loop.
- More pertient to this post, there's no guaranteed order to which the watchers would run, so you could potentially run into a situation where change detection in a child direcive/component would be evaulated before its parent, leading to some weird results. In a visual tree structure, change detection may very well end up traversing each node like so:
Happy Trees
Since Angular 2+, apps are by definition built with a nested tree structure, starting with the root component. Therefore, no more potiential for the "chicken or egg" dilemma in terms of change detection between parent/child components.
Angular 2+ change detection uses a directional tree graph, which basically means changes are guaranteed to propogate unidirectionally, and will always traverse each component instance once starting from the root.
Sounds great, what's the issue?
The directed tree graph makes things much faster, and this built-in behavior will usually be the only thing you'll need in your Angular 2+ app. However, since the focus of this post is optimization, our question would be: What happens when a node (nested component) further down the tree registers a change?
As mentioned above, Angular will always traverse each component instance once, starting from the root. Additionally, since JavaScript does not have object immutability, Angular must be conservative and check to make sure that each component instance hasn't changed since change detection was last run.
What if we only want to run change detection under certain circumstances? There's actually a few ways to do this, but we'll cover the most basic one below.
Learning by example
Let's take a look at a small sample app that lists movies of different categories (New, Upcoming, Top Rated, etc). The MovieDetailsComponent
takes an @Input()
object to represent the movie
list we want details for.
import {
Component,
Input,
...
} from '@angular/core';
@Component({
selector: 'movie-details',
...
})
export class MovieDetailsComponent {
@Input('movie') movieData: Movie;
...
}
<movie-details
[movie]="movie"
*ngFor="let movie of movies">
</movie-details>
The Movie
type is an interface with movie detail-related properties, as well as a flag labeled markedToSee
.
export interface Movie {
id: number;
title: string;
release_date: string;
overview: string;
...
markedToSee: boolean;
}
Let's alias the movie
object to the variable movieData
inside the MovieDetailsComponent
so we can "hook into" the movie
object being called and insert a statement to log to the console. ES6 allows us to use the get syntax to "bind an object property to a function that will be called when that property is looked up".
import {
Component,
Input,
...
} from '@angular/core';
@Component({
selector: 'movie-details',
...
})
export class MovieDetailsComponent {
@Input('movie') movieData: Movie;
get movie() {
console.log(`GET movie: ${this.movieData.title}`);
return this.movieData;
}
...
}
In our list of movies, we have a link at the top that, when clicked, will randomly select a movie for us to see. This will be marked with some text and a background highlight color. Also, we can see the change detection being triggered on every MovieDetailsComponent
instance when the markToSee
flag is flipped on "only one movie".
The event function tied to this action looks like this:
// In parent component
pickMovie(event: any) {
event.preventDefault();
let movie: Movie = this.movies.find(movie => movie.markedToSee);
// If a movie was already marked to see, set the flag back to false
if (movie) {
movie.markedToSee = false;
}
// Mark a random movie to see, mark the flag as true
this.movies[Math.floor(Math.random() * this.movies.length)].markedToSee = true;
}
And testing it out in the browser...
We see that both times we clicked Pick random movie to see, it called the getter function for the movie
object of every MovieDetailsComponent
. It also called the getter multiple times for each property binding in the view (title, overview, etc), but that's just a side note. Ideally, we only want to see the getter function called on one or two of the components (one if it's the first time we're picking a movie to see, two if we've clicked it again and
the markedToSee
flag has been flipped off for the old movie and flipped on for the new movie).
Taking change detection into our own hands (sort of)
We can tell Angular to be more aggressive about deciding when to use change detection by changing the ChangeDetectionStrategy. We are using the default value right now, which will always check for updates on a component – if we explicitly mark it as OnPush
, it will only run change detection when:
- The reference to an
@Input()
object is changed - An event is triggered internally within the component
import {
Component,
Input,
ChangeDetectionStrategy,
...
} from '@angular/core';
@Component({
selector: 'movie-details',
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
IT BROKE!!!!!!!
Sure enough, if you go and check in the browser again, you'll see that "pick random movie to see" is borken — it no longer chooses a movie for us and we don't see the logger statements in the console either, which is another verification. markedToSee
is a property on the movie
object, and the movie
object is an @Input()
property, so the object "changed" right?
Well, yes and no. The object itself changed but the reference to the object didn't change, it's still the same object. As mentioned above, change detection will only occur if the object reference changes. How can we tweak this so we will get the results we want?
An easy way is to look at the markedToSee
property itself. This logic doesn't really belong in the Movie
type as it relates to view logic and user-specific logic, so what if we moved it out into a separate @Input()
property on the MovieDetailsComponent
(since it's the parent component's responsibility for updating that flag anyways)?
First the component...
...
export class MovieDetailsComponent {
@Input('movie') movieData: Movie;
@Input() markedToSee: boolean;
...
}
Then the HTML tag...
<movie-details
[movie]="movie"
[markedToSee]="index === selectedMovieIndex"
*ngFor="let movie of movies; let index = index">
</movie-details>
And finally the function for updating the markedToSee
movie...
// In parent component
...
export class MoviesComponent {
selectedMovieIndex: number;
pickMovie(event: any) {
event.preventDefault();
this.selectedMovieIndex = Math.floor(Math.random() * this.movies.length);
}
}
Success!
We see that the first time the link is clicked, only one component instance is triggered for update. Then, on each subsequent click of the link, we see change detection triggered for the previously selected movie as well
as the newly selected movie. This greatly reduces the number of checks that are needed for a relatively simple change.
Also, if our MovieDetailsComponent
had nested components within it as child components, we could hypothetically skip entire subtrees while doing change detection by using ChangeDetectionStrategy.OnPush
.
Further reading
Finally, as I mentioned previously, there are a few ways to take more control over Angular's change detection, and we covered one of them. For greater control over the individual change detection mechanism of a component instance, check out the documentation for ChangeDetectorRef.
Conclusion
Angular 2 and above give us greater control over change detection than we've ever had with the Angular ecosystem. It is truly a major boost from simply running through a huge list of watchers every time a change is made. Though the default mechanism is already pretty fast, we can further tune our change detection engine when and where it's needed. The example I used leaves out a lot of code for the sake of this article, but you can find the full source code as part of an online Angular 2+ class I taught here.
Happy coding! 😃
This post was originally published by the author here. This version has been edited for clarity and may appear different from the original post.