From zone.js to zoneless Angular and back — how it all works
Max
Posted on January 25, 2023
This article is an excerpt from my Angular Deep Dive course
Autorun with zones
Change detection (rendering) in Angular is usually triggered completely automatically as a result of async events in a browser. This is made possible by utilizing zones implemented by zone.js library. In general, zones provide a mechanism to intercept the scheduling and calling of asynchronous operations. Interceptor logic can execute additional code before or after the task and notify interesting parties about the event. These rules are defined individually for each zone when it’s being created.
Zones are composed in a hierarchical parent-child relationship. At the start the browser runs in a special root zone, which is configured to behave exactly like the platform, making any existing code which is not zone-aware behave as expected. Only one zone can be active at any given time, and this zone can be retrieved through Zone.current
property:
If you’re interested to learn how to work with zone.js directly, check out this article.
Contrary to popular belief, zones are not part of the change detection mechanism in Angular. In fact, Angular can work without zones using change detection services. To enable automatic change detection, Angular implements NgZone service that forks a child zone and subscribes to notifications from this zone.
This zone is referred to as Angular zone and all application specific code is expected to run inside this zone. That’s because NgZone
only gets notifications about events that occur inside this Angular zone and doesn’t get any notifications about events in other zones:
If you explore NgZone
, you’ll see that the reference to the forked Angular zone is stored in the _inner
property:
export class NgZone {
constructor(...) {
forkInnerZoneWithAngularBehavior(self);
}
}
function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
zone._inner = zone._inner.fork({ ... });
}
This is the zone that is used to run a callback when you execute NgZone.run()
:
export class NgZone {
run(fn, applyThis, applyArgs) {
return this._inner.run(fn, applyThis, applyArgs);
}
}
The current zone at the moment of forking the Angular zone is kept in the _outer
property and is used to run a callback when you execute NgZone.runOutsideAngular()
:
export class NgZone {
runOutsideAngular(fn) {
return (this as any as NgZonePrivate)._outer.run(fn);
}
}
Most often this outer zone is the top-most “root” zone.
When Angular finished its bootstrapping, that’s the hierarchy of zones you get:
"root"
"angular"
Which you can easily see for yourself by simply logging the corresponding properties:
export class AppComponent {
constructor(zone: NgZone) {
console.log((zone as any)._inner.name); // angular
console.log((zone as any)._outer.name); // root
}
}
However, in the development mode, there’s also AsyncStackTaggingZone
that sits in between root
and angular
zone like this:
"root"
"AsyncStackTaggingZone"
"angular"
In this case, NgZone
instance keeps a reference to the AsyncStackTaggingZone
in its _inner
property. AsyncStackTaggingZone
provides linked stack traces to show where the async operation is scheduled. For more details, refer to the article Better Angular Debugging with DevTools.
The last bit that we need to know is that Angular instantiates NgZone
during the bootstrapping phase:
export class PlatformRef {
bootstrapModuleFactory<M>(moduleFactory, options?) {
const ngZone = getNgZone(options?.ngZone, getNgZoneOptions(options));
const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}];
// all initialization logic runs inside Angular zone
return ngZone.run(() => {...});
}
}
Angular uses the onMicrotaskEmpty event inside ApplicationRef
to automatically trigger change detection for the entire application:
@Injectable({providedIn: 'root'})
export class ApplicationRef {
constructor(
private _zone: NgZone,
private _injector: EnvironmentInjector,
private _exceptionHandler: ErrorHandler,
) {
this._onMicrotaskEmptySubscription = this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick();
});
}
});
}
}
Let’s now see how Angular can work without zones.
Zoneless application
To run an Angular application without zone.js
we need to pass noop
value into bootstrapModule
function for ngZone
parameter like this:
platformBrowserDynamic()
.bootstrapModule(AppModule, {
ngZone: 'noop'
});
If we now run this simple application:
@Component({
selector: 'app-root',
template: `{{time}}`
})
export class AppComponent {
time = Date.now();
}
we’ll see that change detection is fully operational and renders time
value in the DOM.
However, if update the name
property inside the callback for setTimeout
:
@Component({
selector: 'app-root',
template: `{{time}}`
})
export class AppComponent {
time = Date.now();
constructor() {
setTimeout(() => { this.time = Date.now() }, 1000);
}
}
We’ll see that the change is not updated. This is expected behavior since there’s no Angular zone to notify Angular about the timeout event occurrence. What’s interesting that we can still inject NgZone
into the constructor:
import { ɵNoopNgZone } from '@angular/core';
export class AppComponent {
constructor(zone: NgZone) {
console.log(zone instanceof ɵNoopNgZone); // true
}
}
But it’s an empty implementation of NgZone
which does nothing. We could use a change detector service to manually run change detection:
@Component({
selector: 'app-root',
template: `{{time}}`
})
export class AppComponent {
time = Date.now();
constructor(cdRef: ChangeDetectorRef) {
setTimeout(() => { this.time = Date.now() }, 1000);
cdRef.detectChanges();
}
}
We’ll go over this service in detail in
manual control chapter.
Running code inside Angular zone
You may find yourself in the situation where you have a function that somehow runs outside of Angular's zone and you don’t get the beneif to automatic change detection. It’s a common scenario with a third party library doing its stuff unaware of Angular context.
Here is an example of such question involving Google API Client Library (gapi). The common culprit is using techniques like JSONP that don’t use common AJAX APIs like
XMLHttpRequest or Fetch API which are patched and tracked by Zones. Instead, it creates a script
tag with a source URL and defines a global callback to be triggered when the requested script with data is fetched from the server. This can’t be patched or detected by Zones and hence the frameworks remains oblivious to requests performed using this technique.
The common solution to such problems is to simply run a callback inside Angular zone like this. For example, for gapi we should do it like this:
// Load the JavaScript client library.
gapi.load('client', ()=> {
// Run initialization code INSIDE Angular zone
NgZone.run(()=>{
// Initialize the JavaScript client library
gapi.client.init({...}).then(function() { ... });
});
});
Another example is a component that emits notifications from a callback that runs outside the Angular zone:
@Component({
selector: 'n-cmp',
template: '{{title}} <div><n1-cmp></n1-cmp></div>'
})
export class N {
title = 'N component';
emitter = new Subject();
constructor(zone: NgZone) {
zone.runOutsideAngular(() => {
setTimeout(() => {
this.emitter.next(3);
}, 1000);
});
}
}
If we simply subscribe to the changes in the child N1
component like this:
@Component({
selector: 'n1-cmp',
template: '{{title}}, emitted value: {{value}}'
})
export class N1 {
title = 'Child of N';
value = 'nothing yet';
constructor(parent: N, zone: NgZone) {
parent.emitter.subscribe((v: any) => {
this.value = v;
});
}
}
we won’t see any updates on the screen, even though the this.value
is updated to 3
after the parent emits it.
To fix this, just like in the gapi
example, we could run the callback in Angular zone:
@Component({...})
export class N1 {
title = 'Child of N';
value = 'nothing yet';
constructor(parent: N, zone: NgZone) {
parent.emitter.subscribe((v: any) => {
zone.run(() => {
this.value = v;
});
});
}
}
this fixes it.
Posted on January 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.