The Curious Case of Angular and the Infinite Change Event Loop
Angular change detection is a mystical wonder; akin to the Great Pyramid of Giza. This is the behind-the-curtain magic that makes Angular ‘just work’. Today we pull back the curtain on Angular change events and discover when they don’t ‘just work’ (and how to fix them).
I encourage you to follow along…
See the final project in the repository
Built with
- Angular v9.1.1
Getting Started
Prerequisites
Get the newest vscode => https://code.visualstudio.com/download
Get the newest LTS node => https://nodejs.org
Installation
-
Open a terminal where you want the project to be installed
-
Install the Angular CLI globally
npm install -g @angular/cli
-
Create a new Angular project
ng new infinite-loop --interactive=false
-
Navigate into the repository
cd infinite-loop
-
Open the project in vscode
code .
-
Pop open the vscode terminal
CTRL + `
-
Start up the project
npm start
-
Open a Chrome browser here.
http://localhost:4200
The Code
All started up? Great! Now let’s put some of our code in.
-
Kill the server
CTRL + C
-
Generate a new component named “hello”
ng generate component hello
-
In
src/app/hello/hello.component.html
<p *ngIf="!isLoggedIn()">Are you there?</p> <h1 *ngIf="isLoggedIn()">Hello {{user?.first}}!</h1>
-
In
src/app/hello/hello.component.ts
import { Component, Input, AfterViewChecked } from "@angular/core"; @Component({ selector: "hello", templateUrl: "./hello.component.html", styleUrls: ["./hello.component.css"], }) export class HelloComponent implements AfterViewChecked { @Input() user: any; constructor() {} ngAfterViewChecked() { console.count("ngAfterViewChecked"); } isLoggedIn() { if (this.user?.first) return true; else return false; } }
-
In
src/app/app.component.html
<hello [user]="user"></hello> <button (click)="login($event)">Login</button>
-
In
src/app/app.component.ts
import { Component } from "@angular/core"; @Component({ selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.css"], }) export class AppComponent { user = { first: "", last: "" }; login(e: MouseEvent) { this.user.first = "Bob"; this.user.last = "Smith"; } }
-
Start up the project again
npm start
This is how it should look. Clicking the login button calls the login method which changes the user’s name. The user
object is passed into the hello
component. The component checks for a first name and displays if one exists. In the hello component we log every time the ngAfterViewChecked
Angular lifecycle hook is invoked.
Infinite Event Loop
Now, let’s say we want to do something 1 second after the hello component checks for login.
- In
src/app/hello/hello.component.ts
import { Component, Input, AfterViewChecked } from "@angular/core";
@Component({
selector: "hello",
templateUrl: "./hello.component.html",
styleUrls: ["./hello.component.css"],
})
export class HelloComponent implements AfterViewChecked {
@Input() user: any;
constructor() {}
ngAfterViewChecked() {
console.count("ngAfterViewChecked");
}
isLoggedIn() {
setTimeout(() => {
// do something after 1 second
}, 1000);
if (this.user?.first) return true;
else return false;
}
}
Woopsie! We have just entered an endless event loop and my Macbook Pro gave me third-degree burns on my thighs.
The Fix
So what happened?
Calling setTimeout
causes a change event to fire. The change event cause the isLoggedIn()
method to run. Which causes another setTimeout
and so on and so on. This is the default change detection strategy. To make Angular “just work” it checks for changes on several things. There are a couple things we can do to fix this. And make our application more efficient too.
- NgZone
- OnPush
NgZone
- In
src/app/hello/hello.component.ts
import { Component, Input, AfterViewChecked, NgZone } from "@angular/core";
@Component({
selector: "hello",
templateUrl: "./hello.component.html",
styleUrls: ["./hello.component.css"],
})
export class HelloComponent implements AfterViewChecked {
@Input() user: any;
constructor(private zone: NgZone) {}
ngAfterViewChecked() {
console.count("ngAfterViewChecked");
}
isLoggedIn() {
this.zone.runOutsideAngular(() => {
setTimeout(() => {
// do something after 1 second
}, 1000);
});
if (this.user?.first) return true;
else return false;
}
}
We wrapped our setTimeout
with NgZone.runOutsideAngular
. This tells Angular to not fire a change event for this section of code.
OnPush
- In
src/app/hello/hello.component.ts
import {
Component,
Input,
AfterViewChecked,
ChangeDetectionStrategy,
} from "@angular/core";
@Component({
selector: "hello",
templateUrl: "./hello.component.html",
styleUrls: ["./hello.component.css"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HelloComponent implements AfterViewChecked {
@Input() user: any;
constructor() {}
ngAfterViewChecked() {
console.count("ngAfterViewChecked");
}
isLoggedIn() {
setTimeout(() => {
// do something after 1 second
}, 1000);
if (this.user?.first) return true;
else return false;
}
}
We set the Angular change detection strategy from ChangeDetectionStrategy.Default
(look for changes everywhere) to ChangeDetectionStrategy.OnPush
which will only detect changes when the @Input()
has a new object pushed on it. In this case, when there is a new user
object.
But, whoops. Nothing happened when I clicked the login button. The problem here is that we are directly mutating the existing user
object.
app.component.ts
...
login(e: MouseEvent) {
this.user.first = 'Bob';
this.user.last = 'Smith';
}
...
With the OnPush
strategy we must push a new object through the hello
component. Try this instead.
-
In
src/app/app.component.ts
import { Component } from "@angular/core"; @Component({ selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.css"], }) export class AppComponent { user = { first: "", last: "" }; login(e: MouseEvent) { this.user = { first: "Bob", last: "Smith" }; } }
There. Now its working.
But look carefully and you will notice that 1 second after the the login button is pressed there are some more events. This is due to the setTimeout
returning a second later.
Conclusion
In conclusion, which is best for solving the infinite event loop, 1) NgZone or 2) OnPush? Plot twist! I choose 3) All of the above.
- In
src/app/hello/hello.component.ts
import {
Component,
Input,
AfterViewChecked,
NgZone,
ChangeDetectionStrategy,
} from "@angular/core";
@Component({
selector: "hello",
templateUrl: "./hello.component.html",
styleUrls: ["./hello.component.css"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HelloComponent implements AfterViewChecked {
@Input() user: any;
constructor(private zone: NgZone) {}
ngAfterViewChecked() {
console.count("ngAfterViewChecked");
}
isLoggedIn() {
this.zone.runOutsideAngular(() => {
setTimeout(() => {
// do something after 1 second
}, 1000);
});
if (this.user?.first) return true;
else return false;
}
}
And now you are a little bit cooler programmer.