Angular and Zone.js

Angular and Zone.js

Get in the Zone? Or out of it...?

If you're a fan of Andrey Tarkovsky's movie, Stalker, you'll probably know that The Zone could be a fortune but also a terror.

The funny thing, the same happens with Zone.js and Angular.

The motivation behind this article

The motivation for this article is my journey to understand what's with all the hype around Signals. Can we compare it to the hype that was back when Zone.js was released almost 9 years ago?

To understand where we're going is necessary to understand where we are.


What is Zone.js more precisely?

A Zone is an execution context that persists across async tasks.

From Zone.js npm readme

Pretty complex, huh? Let's divide-et-impera this one without getting into the rabbit hole

Execution Context - (the super short version)

Going a bit low-level inside JavaScript, an execution context is an abstract concept that holds information about the environment in which the code is executed.

There is a Global Execution Context which is initiated when you run the code and then you have Function Execution Context which is initiated every time you call a function.

The information consists of:

  • Variable Object - all the variables, functions and parameters declared in the current context

  • Scope Chain - hierarchy of variable objects

  • this - Reference to the object that executes the current function

Async Tasks

Usually, asynchronous tasks are referring to any activity that occurs outside of the main thread. Think about reading from a file, intervals, promises, network activity or any other I/O activity.

There are two types of async tasks in the event loop:

  • Macro Tasks - Tasks executed by the browser's event loop (Rendering, User Input, setTimeout)

  • Micro Tasks - Tasks executed as part of the current task execution. They have a higher priority than macro-tasks. Most of them are Promise callbacks.

Let's put them together

Combining the two topics above, we understand that we need an execution context between the JS Code and the Browser API or any other async tasks.

The way I see it, it looks something like this

Zone.js will 'monkey patch' most of the Browser API. Going inside the Zone.js code a bit we will find snippets like these

...
Zone.__load_patch('queueMicrotask', (global: any, Zone: ZoneType, api: _ZonePrivate) => {
  api.patchMethod(global, 'queueMicrotask', delegate => {
    return function(self: any, args: any[]) {
      Zone.current.scheduleMicroTask('queueMicrotask', args[0]);
    }
  });
});
...

Or, in the case of setTimeout or setInterval:

...
Zone.__load_patch('timers', (global: any) => {
  const set = 'set';
  const clear = 'clear';
  patchTimer(global, set, clear, 'Timeout');
  patchTimer(global, set, clear, 'Interval');
  patchTimer(global, set, clear, 'Immediate');
});
...

An important aspect is the capability of patching asynchronous tasks, Zone.js creates lifecycle hooks for these tasks so you can profile, debug or log all the asynchronous communication with Browser API.

Zone.js comes with four life hooks that you can use:

Method NameDescription
onScheduleTaskTriggers when you call setTimeout
onInvokeTaskTriggers when the callback of setTimeout is called
onHasTasksChecks if the status of the zone is Stable (no tasks) or Unstable (there is a task scheduled in the zone)
onInvokeTriggers when a synchronous function is called

Each time you will call those methods(in case you want to manually use them or build your framework), you will have room to extend. Sounds SOLID to me.

TLDR: Add the capability to observe and execute code based on the states of synchronous and asynchronous operations.


NgZone

Ok, now we're getting into The Zone. We have to understand that Zone is not something that is Angular related. Look at Zone as a pattern or a tool that Angular is using to build the NgZone

NgZone is a fork of Zone. It extends some of the functionalities that Zone.js already provides and also it links the execution of async tasks to change detection.

If you dig deeper inside Angular's repo, you'll find ng_zone.ts with the following snippet (I've cleared up a bit to focus on the change detection).

/// ng_zone.ts
function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
  const delayChangeDetectionForEventsDelegate = () => {
    delayChangeDetectionForEvents(zone);
  };
  zone._inner = zone._inner.fork({
    name: 'angular',
    properties: <any>{'isAngularZone': true},

    onInvokeTask: (...): any => {
            ...
            try {
            onEnter(zone);
            return delegate.invokeTask(target, task, applyThis, applyArgs);
          } finally {
            onLeave(zone);
          }
            ...
        },

    onInvoke: (...): any => {
            ...
            try {
            onEnter(zone);
            return delegate.invoke(target, callback, applyThis, applyArgs, source);
          } finally {
            onLeave(zone);
          }
            ...
        },

    onHasTask:
        (...) => {
            ...
            checkStable(zone);
            ...
        },

    onHandleError: (...): boolean => {
            ...
             delegate.handleError(target, error);
      zone.runOutsideAngular(() => zone.onError.emit(error));
            ...
    }
  });
}

function onEnter(zone: NgZonePrivate) {
  zone._nesting++;
  if (zone.isStable) {
    zone.isStable = false;
    zone.onUnstable.emit(null);
  }
}

function onLeave(zone: NgZonePrivate) {
  zone._nesting--;
  checkStable(zone);
}

function checkStable(zone: NgZonePrivate) {
  if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
    try {
      zone._nesting++;
      zone.onMicrotaskEmpty.emit(null);
    } finally {
      zone._nesting--;
      if (!zone.hasPendingMicrotasks) {
        try {
          zone.runOutsideAngular(() => zone.onStable.emit(null));
        } finally {
          zone.isStable = true;
        }
      }
    }
  }
}

Bear with me. We're simplifying this.

We can see in the example above how Angular is creating its own Zone(NgZone) with its extensions and also the functions of onEnter(), onLeave() and checkStable()

A key aspect here is the zone.onMicrotaskEmpty.emit(). Angular's change detection mechanism listens to this event and triggers the rendering of the whole component tree. You can find that in application_ref.ts

/// application_ref.ts
class NgZoneChangeDetectionScheduler {
    ...
    this._onMicrotaskEmptySubscription =              this.zone.onMicrotaskEmpty.subscribe({
      next: () => {
        this.zone.run(() => {
          this.applicationRef.tick();
        });
      }
    });
}
...
/// Tick Function
tick(): void {
      ...
      this._runningTick = true;
      for (let view of this._views) {
        view.detectChanges();
      }
      ...
  }

Let's visualize this as a state diagram:

Trade-off

I can see why this would make sense. It builds a developer experience, you'll abstract the trigger of change detection.

You will not care when or how it will be triggered. You just take change detection for granted.

I can also see the downside. You rely on an automatic system that if you don't understand it properly can go against you.


Can I go 'zoneless'?

Of course!

As I mentioned, NgZone just helps Angular detect when to trigger the Change Detection cycle. That doesn't mean you can't trigger it yourself.

You have two ways to do that. Either you run snippets of code outside of NgZone or you can disable it completely.

Running code outside of NgZone

All you have to do is inject NgZone inside your component and use runOutsideAngular

...
  public date = new Date()
  constructor(private ngZone: NgZone) {}
  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      setTimout(() => {
         date = new Date()
      })
    });
  }
...

The problem that you're going to encounter here is that if you're using data in your template, running it like this will not trigger change detection.
That means the new value will not get rendered.

To fix that, you'll have to call the change detection manually. You can do this by injection changeDetectorRef and call either markForCheck() or detectChanges()

Remember:

  • markForCheck() - marks the component and its parents as dirty, but does not immediately trigger change detection. Will check only at the next cycle

  • detectChanges() - immediately triggers change detection for the component and its children, even if the component is not marked as dirty

...
  public date = new Date()
  constructor(private ngZone: NgZone
              private cdr: ChangeDetectionRef) {}
  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      setTimout(() => {
         date = new Date()
         this.cdr.detectChanges()
      })
    });
  }
...

Disable NgZone

Very simple.

In src/main.ts you can edit the bootstrapModule and add this. This means you will have to trigger change detection manually each time you want to trigger change detection.

platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' })

Conclusions

In the past, I always got scared of Zone.js. I had no idea what it meant. It always seemed like a magical place where something related to Angular happens.

For me, it was very helpful to dig deeper inside the Angular code, it's interesting to learn patterns and understand a bit more context of why moving to Signals is the next step.

I do believe that there are a lot of other resources that you can check to understand this better. I'm going to list a couple of them that I found useful: