From zone.js to zoneless Angular and back — how it all works

maxkoretskyi

Max

Posted on January 25, 2023

From zone.js to zoneless Angular and back — how it all works

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:

Image description

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:

Image description

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({ ... });
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

However, in the development mode, there’s also AsyncStackTaggingZone that sits in between root and angular zone like this:

"root"
   "AsyncStackTaggingZone"
               "angular"
Enter fullscreen mode Exit fullscreen mode

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(() => {...});
        }
}
Enter fullscreen mode Exit fullscreen mode

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();
        });
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
    });
Enter fullscreen mode Exit fullscreen mode

If we now run this simple application:

@Component({
  selector: 'app-root',
  template: `{{time}}`
})
export class AppComponent {
  time = Date.now();
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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() { ... });
    });
});
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

this fixes it.

💖 💪 🙅 🚩
maxkoretskyi
Max

Posted on January 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Angular Form Array
angular Angular Form Array

November 29, 2024

Can a Solo Developer Build a SaaS App?
undefined Can a Solo Developer Build a SaaS App?

November 29, 2024

Angular's New Feature: Signals
javascript Angular's New Feature: Signals

November 29, 2024