Evgeniy OZ
Posted on March 16, 2024
This article explains how to modify your code to make your library or app work without Zone.js.
What is Zoneless Angular?
In the article “Angular Change Detection — Today and Tomorrow”, I’ve explained how different types of change detection work in Angular.
The main benefit of making Angular apps zoneless is performance. No more unnecessary CD cycles and recomputations of values. No more checking every binding on every mousemove
event. No more runOutsideAngular()
calls to reduce the amount of CD cycles, no more juggling with detach()
and detectChanges()
.
Another benefit: we can safely use async..await
syntax because we don’t rely anymore on monkey-patching Promises by Zone.js.
Removing Zone.js dependency also removes the “Angular-compatible” term for JS tools: you don’t need to worry anymore if changes that some JS tool makes will be caught by Angular’s Change Detection and rendered correctly.
Prerequisites
Before your app goes zoneless, it should be “OnPush compatible”. It means, that every binding in your template is reactive.
Reactive bindings will notify Angular that their value has changed, so the DOM might need to be modified.
Examples of non-reactive bindings:
@Component({
template: `
<div> {{ name }} </div>
<child [value]="value"/>
`
})
export class ExampleComponent {
name = "Alice";
value = "Bob";
}
The bindings {{ name }}
and [value]=”value”
lack reactivity — they won’t update if the values of name or value are modified because the template cannot know about it.
Reactive bindings:
@Component({
template: `
<div> {{ name() }} </div>
<child [value]="value | async"/>
`
})
export class ExampleComponent {
name = signal("Alice");
value = new BehaviorSubject("Bob");
}
Here, the template will get a notification when name
or value
are updated. What happens in the template (what is marked as dirty, what is marked for traversal) — just implementation details, it doesn’t matter. What matters is: when notified about the changes, Angular knows what part of the views tree it should update.
There are only two requirements:
- Every binding in your templates should be reactive;
- Angular v17.1+.
No other things are required. If all components in your app have changeDetection: ChangeDetectionStrategy.OnPush
then your app will work fine in “zoneless” mode. There are many new things in the latest releases of Angular, but they are not required to be used to make your app zoneless.
Preparing your code
NgZone has a few methods that your app or library may use:
-
onMicrotaskEmpty()
; -
onStable()
; -
onUnstable()
; -
onError()
; -
run()
,runOutsideAngular()
,runGuarded()
,runTask()
.
run*()
methods are not needed in Zoneless Angular: you can just call your function directly.
Was:
@Injectable()
export class ExampleService {
private ngZone = inject(NgZone);
draw() {
this.ngZone.runOutsideAngular(() => this.putPointsToCanvas());
}
private putPointsToCanvas() {}
}
Now:
@Injectable()
export class ExampleService {
draw() {
this.putPointsToCanvas();
}
private putPointsToCanvas() {}
}
To replace onStable()
and onMicrotaskEmpty()
, you can use afterRender()
or afterNextRender()
.
afterRender()
will be called after every rendering of the application (not some component), and afterNextRender()
will be called after the next rendering event, once. Notice that both of them have phases (second argument).
Was:
@Injectable()
export class ExampleService {
private ngZone = inject(NgZone);
draw(): Observable<string> {
this.ngZone.onStable().pipe(
tap(() => this.putPointsToCanvas())
);
}
private putPointsToCanvas() {}
}
Now:
@Injectable()
export class ExampleService {
private readonly afterNextRender$ = new Subject();
constructor() {
afterNextRender(() => this.afterNextRender$.next({}));
}
draw(): Observable<string> {
this.afterNextRender$.pipe(
tap(() => this.putPointsToCanvas())
);
}
private putPointsToCanvas() {}
}
This code will work both in Zoneless and “regular” Angular.
Notice that you can use afterRender()
and afterNextRender()
whenever you want; just call them in an injection context. Because, as the documentation says: “The execution of render callbacks is not tied to any specific component instance, but instead an application-wide hook”.
StackBlitz example:
Third-party libraries
The main purpose of this article is to help library maintainers in preparing their code for Zoneless Angular. But sometimes we use a library that is too large to fork, and we need to make it work in our application right now.
For libraries that are not updated yet and use onStable()
or onMicrotaskEmpty()
, we can emulate the behavior of onStable()
and onMicrotaskEmpty()
, and later remove all of these emulations when all the libraries we use are ready for Zoneless Angular (or if and when Angular provides its own emulation).
I’ve created a helper with functions that override the NoopNgZone implementation and emulate the onStable()
behavior: GitHub link. I don’t publish it as a library because it is a temporary solution.
I’ll repeat this because it is important: please update your apps and libraries to not rely on NgZone interface (see above how to modify your code). Do not make your code rely on emulations made by this code snippet. Only use it to temporarily workaround limitations of the libraries you cannot modify.
Let me explain how this emulation works:
export class NoopNgZone implements NgZone {
// ...
private readonly onStableEmitter = new EventEmitter<any>();
private readonly schedule = getCallbackScheduler();
get onStable() {
this.schedule(() => this.onStableEmitter.emit({}));
return this.onStableEmitter;
}
// ...
}
When code calls NgZone.onStable()
, our getter returns an Observable. Before returning it, we schedule an emission of the new value (which would mean that the zone is “stable”).
For scheduling, I use awesome code I found in the Angular source code.
Sometimes, libraries are so tightly bound to Zone.js that they expect onStable()
or onMicrotaskEmpty()
to emit quite often. For them, one event that we emit is not enough.
To make them work, you can find an event after which that library wants to do its job and artificially emit onStable()
or onMicrotaskEmpty()
when this event happens:
export class ExampleComponent {
private readonly ngZoneNoop = inject(NgZone) as NoopNgZone;
private readonly docClickHandler = () => this.ngZoneNoop.emitOnStable();
constructor() {
document.addEventListener('click', this.docClickHandler);
}
ngOnDestroy() {
document.removeEventListener('click', this.docClickHandler);
}
}
In the example above, we are listening for click events and making our NgZone implementation emit onStable
event on every click.
I hope you will never need to use a workaround like this, and all the libraries will quickly adapt to Zoneless Angular.
Converting our Angular app to zoneless
This part is pretty easy:
- Provide a custom NgZone implementation and a scheduler:
bootstrapApplication(AppComponent, {
providers: [
ɵprovideZonelessChangeDetection(), // imported from `@angular/core`
]
});
- Remove the import of zone.js in your polyfills.ts file, or (if you don’t have this file), remove the “zone.js” line from the polyfills array in the angular.json file.
That’s it!
You can see that it doesn’t require a lot of changes if your app or lib is already “OnPush-compatible”.
Authors of libraries, please consider making your code compatible with Zoneless Angular. It will not take a lot of effort: afterRender()
here, afterNextRender()
there and that’s it 😉
The Angular Team needs your feedback, they want to know about the cases that don’t work and where
afterRender()
is not good enough. Let’s start preparing our codebases and provide them feedback!
I extend my heartfelt gratitude to the reviewers whose insightful comments and constructive feedback greatly contributed to the refinement of this article:
🪽 Do you like this article? Share it and let it fly! 🛸
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
Posted on March 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.