Christian Kohler
Posted on March 22, 2020
tl;dr
Angular change detection relies on Zone.js which works well in most situations but is hard to debug and might lead to performance problems.
With the rise of reactive programming in Angular we might not need Zone.js at all and instead trigger change detection whenever the view state changes.
Michael Hladky and the ngrx team are working on a library named ngrx/component to make it easier to trigger change detection with observables.
In this article we look at how this new library helps us write maintainable code without Zone.js
TOC
- π€ What's wrong with Zone.js?
- π¦ When should we run change detection?
- π² Zone less approach in Angular
- π‘ Change detection in a reactive application
- π§ Async Pipe
- π ngrx PushPipe and let directive
- β Should I now rewrite my code?
- π©βπ Be ready for a reactive (zone-less) future
- π Resources
What's wrong with Zone.js? π€
Most Angular developers learn about Zone.js when they run into change detection issues. Zone.js doesn't do change detection but triggers it after async operations were completed. One good example is a setTimeout()
callback after which Zone.js triggers change detection.
In general Zone.js just works and helps Angular developers write less code.
But every design decision has its pros and cons.
The problems withs Zone.js are
- it's hard to debug
- it might lead to performance issues
- no native async await support (Typescript target: ES2017 or higher)
Zones.js allows for a mix of imperative and reactive code
I often see how part of an Angular application is written in a imperative way and part of it reactive. It's not always a bad thing but I feel that the mix often makes it harder to read code.
In 2019 Rob Wormald talked about Zone.js in his keynote. He said:
Zones are great until they are not.
Watch the full keynote here: Rob Wormald - Keynote - What's New and Coming in Angular | AngularUP 2019
When should we run change detection? π¦
With Zone.js Angular change detection magically works for almost any scenario. To make it work, it assumes that whenever you have an event like a click event, the state changed and the view has to be rerendered.
Let's take that simple example:
@Component({
template: `
<div>Count is {{ count }}</div>
<button (click)="increment()">Increment</button>
<button (click)="noEffect()">Dummy Button</button>
`
})
export class AppComponent {
count = 0;
// triggers change detection
increment() {
this.count = this.count + 1;
}
// also triggers change detection
noEffect() {}
}
In this example Angular needs to trigger change detection after the increment
method because we want to update our view. But we don't need to trigger change detection after we call the noEffect
method
Ideally we only trigger change detection when the view state changes
The React way
In React you change the state explicitly which then triggers a rerender. In the following example the setCount sets part of the state.
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
This approach is easy to understand and to debug.
Zone less approach in Angular π²
So let's get rid of Zone.js and let's try to make change detection more predictable and easier to debug.
We can easily disable Zone.js in Angular by setting ngZone to "noop":
platformBrowserDynamic().bootstrapModule(AppModule, {
ngZone: "noop"
});
Since change detection is not triggered anymore by Zone.js we need to trigger it manually:
export class AppComponent {
count = 0;
constructor(private cdRef: ChangeDetectorRef) {}
increment() {
this.count = this.count + 1;
this.cdRef.detectChanges();
}
noEffect() {}
}
This approach works but involves a lot of manual work and pollutes our code with change detection logic. Also due to the imperative nature of that code it can become very difficult to understand what triggered change detection the more complex the code gets.
Change detection in a reactive application π‘
In a reactive application we know exactly when a change happens. Every time a new value is emitted in a observable. And whenever a change happens we can trigger change detection. This means we don't need to rely on Zone.js to trigger change detection.
When every view state is an observable, we know exactly when to trigger change detection.
Notice how this is a very similar to Reacts approach?
Async Pipe π§
With Zone.js deactivated the first idea for a reactive approach would be to use Angulars async pipe to trigger change detection when a new value is emitted.
@Component({
template: `
<div>Count is {{ count$ | async }}</div>
<button (click)="increment()">Increment</button>
<button (click)="noEffect()">Dummy Button</button>
`
})
export class AppComponent {
increment$ = new Subject();
count$ = this.increment$.pipe(
scan(count => count + 1, 0),
startWith(0)
);
increment() {
this.increment$.next();
}
}
Unfortunately that doesn't trigger change detection since the async pipe only runs markForChecked on the components ChangeDetectorRef.
So we need an async pipe which can trigger change detection. Luckily there is a library coming up for exactly that.
ngrx PushPipe and let directive π
Michael Hladky and the ngrx team are working on a new library named ngrx/component. It's not released yet but we can already try it out. It's a collection of tools to make it easier to write reactive angular components.
Or as Michael Hladky says:
"The idea of ngrx/component is building applications where the word subscribe is not present."
In #rxjs .subscribe() is where reactive programming ends
β Michael Rx Hladky (@michael_hladky ) October 5, 2019
Currently it consists of two features:
- PushPipe, a drop in replacement for the async pipe
- Let directive, an enhancement/alternative to ngIf for binding observable values
PushPipe
The PushPipe is a drop in replacement for the async pipe. It triggers change detection in a zone-less context or triggers markForCheck
like the async pipe in a zone context.
Usage
Replace the async pipe:
{{ count$ | async }}
with the ngrx PushPipe:
{{ count$ | ngrxPush }}
Example
Here is a Stackblitz example with the counter and the PushPipe. Try it out and replace the ngrxPush with async to see how it affects change detection. Also check the PushPipe documentation for more examples.
Let Directive
Another great addition to make it easier to build reactive Angular components is the let-directive.
The let directive is similar to *ngIf but handles 0 values and supports zone-less the same way the PushPipe does. That means it also triggers change detection when a new value is emitted.
The let-directive does not provide the show/hide funtionality which is imho a good design decision. The let-directive binds to observable values and the ngIf can then be used for the show/hide logic. It's a nice seperation of concerns.
Usage
Replace the *ngIf:
<div *ngIf="count$ | async as count">Count is {{ count }}</div>
width *ngrxLet:
<div *ngrxLet="count$ as count">Count is {{ count }}</div>
Example
Here is a Stackblitz example with the counter and the let directive. Try it out and replace the ngrxLet with ngIf to see how it affects change detection. Also check the Let directive documentation for more examples.
How PushPipe and the let-directive improve performance?
PushPipe and the let-directive improve performance in two ways:
- Only trigger change detection when a new observable value is emitted
- Trigger change detection only for the component and its children
Should I now rewrite my code? β
Short answer: Keep Zone.js for now but start using PushPipe and let-directive
What I showed you in the examples is a zone-less full reactive Angular example. You don't have to go zone-less to make your application more reactive. Let's look at the different motivations behind using ngrx/component.
Motivation "Reactive Angular"
If you don't have any problems with Zone.js or performance I would keep Zone.js turned on. Focus on writing reactive code. Ngrx/component makes it easier with features like the let-directive.
Motivation "Zone-less Angular"
You want to get rid of Zone.js and improve performance by only rerendering the current component and its children. A good use case would be Angular Elements. It would simplify the usage of Angular Elements and reduce the bundle size. Ngrx/component is the easiest way to go Zone-less. Only replace the async pipe with the new PushPipe.
𧨠If you turn off Zone.js, some 3rd party libraries might not work anymore. For example, Angular Material select doesn't work out of the box without Zone.js. Try it out and disable Zone.js here.
β¨ Start using PushPipe and the let-directive
π PushPipe and the let directive are not released yet (as of 23 March 2020). I will update this post after the release.
Since both, PushPipe and let-directive, work with Zone.js enabled you can use them as a drop in replacement today. When you ever decide to turn off Zone.js it just works (which is not the case with the async pipe).
The let-directive is also more than just a zone-less ngIf. It seperates the show/hide functionality from binding to observable values.
Be ready for a reactive (zone-less) future π©βπ
Angular makes it easy to write reactive code. Default libraries like the router and the http client provide observables. Ngrx builds on observables. With ngrx/component it gets even easier to write full reactive code. Full reactive code makes it also much easier to know when to trigger change detection and to write zone-less code.
If you are a developer, embrace RxJS and write your code in a reactive way. It will make it easier for you to use new features like the PushPipe.
If you are a 3rd party library maintainer make sure your library works in a zone-less environment.
If you liked the article π, spread the word and follow me on Twitter for more posts on Angular and web technologies.
Huge thanks to Michael Hladky for his inputs and reviews, and the work he put into ngrx/component.
Did you find typos π€? Please help improve the blogpost and open an issue here
Resources π
Posted on March 22, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.