This article is an advanced look at how Angular works under-the-hood. The contents within may not be clear if you're not already fairly familiar with JavaScript. If you want to learn how to use Angular and haven't before, look at my book "The Framework Field Guide", which teaches React, Angular, and Vue from scratch instead.

Reactivity has become a buzzword in the JavaScript framework space recently. You've likely seen discussions ranging from SolidJS's "fine-grained" approach to Preact introducing its own reactive primitive called "Signals."

At its core, reactivity is a simple concept: modifying a piece of code in one location automatically triggers an update in another location. This is a fundamental requirement for front-end frameworks, where changing JavaScript data necessitates re-rendering the corresponding UI.

However, Angular stands out as an outlier in these discussions. Its implementation of reactivity is significantly different from what you'll find in other popular frameworks.

Consider this simple button counter example, implemented in three different frameworks:

Angular

import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
    <button (click)="addOne()">{{count}}</button>
  `,
})
export class AppComponent {
  count = 0;

  addOne() {
    this.count++;
  }
}
Enter fullscreen mode Exit fullscreen mode

React

const App = () => {
    const [count, setCount] = useState(0);

    const addOne = () => setCount(count+1);

    return <button onClick={addOne}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Vue

<template>
    <button @click="addOne()">{{count}}</button>
</template>

<script setup>
import {ref} from 'vue';

const count = ref(0);

function addOne() {
    count.value += 1;
}
</script>
Enter fullscreen mode Exit fullscreen mode

In these examples, you can see that React requires an explicit state update function (setX) to signal a change, while Vue leverages a Proxy and a special property (.value) to track state changes seemingly behind the scenes.

Now, what about Angular?

Angular allows direct mutation of the count variable, and the framework automatically keeps track of the state changes. What's the mechanism behind this? How does Angular know when to re-render the template?

In simple terms, Angular relies on a library called "Zone.js." This library polyfills various asynchronous APIs to track them, and Angular uses these zones to identify and re-render components that have become "dirty."

That might sound like a lot of jargon. Let's break it down.

Let's take a closer, more methodical look at how Angular's rendering and reactivity work with Zone.js. We'll cover the following topics step by step:

  • How the Angular template compiler generates render functions
  • How Angular invokes these template functions to update the UI
  • How Angular detects user-induced changes and refreshes the screen automatically
  • What Zone.js is and how it functions with and without Angular
  • How Angular uses its own internal version of Zone.js, known as NgZone
  • How change detection functions without Zone.js (and why it's a developer experience headache)
  • How Zone.js uses monkey-patching on async APIs to trigger change detection

A Look Inside the Template Compiler

Earlier last year, the Angular team released a blog post titled "How the Angular Compiler Works". In it, they showcase how the NGC compiler transforms the following code:

import {Component} from '@angular/core';

@Component({
  selector: 'app-cmp',
  template: '<span>Your name is {{name}}</span>',
})
export class AppCmp {
  name = 'Alex';
}
Enter fullscreen mode Exit fullscreen mode

And produces the following output:

import { Component } from '@angular/core';                                      
import * as i0 from "@angular/core";

export class AppCmp {
    constructor() {
        this.name = 'Alex';
    }
}                                                                               
AppCmp.ɵfac = function AppCmp_Factory(t) { return new (t || AppCmp)(); };
AppCmp.ɵcmp = i0.ɵɵdefineComponent({
  type: AppCmp,
  selectors: [["app-cmp"]],
  decls: 2,
  vars: 1,
  template: function AppCmp_Template(rf, ctx) {
    if (rf & 1) {
      i0.ɵɵelementStart(0, "span");
      i0.ɵɵtext(1);
      i0.ɵɵelementEnd();
    }
    if (rf & 2) {
      i0.ɵɵadvance(1);
      i0.ɵɵtextInterpolate1("Your name is ", ctx.name, "");
    }
  },
  encapsulation: 2
});                                                   
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(AppCmp, [{
        type: Component,
        args: [{
                selector: 'app-cmp',
                template: '<span>Your name is {{name}}</span>',
            }]
    }], null, null); })();
Enter fullscreen mode Exit fullscreen mode

While that article provides a comprehensive deep dive into the compiler's operations (worth a read!), we'll keep our focus narrower for the purpose of this discussion.

Specifically, we'll examine the template property function found within the ɵɵdefineComponent call.

template: function AppCmp_Template(rf, ctx) {
    if (rf & 1) {
        i0.ɵɵelementStart(0, "span");
        i0.ɵɵtext(1);
        i0.ɵɵelementEnd();
    }
    if (rf & 2) {
        i0.ɵɵadvance(1);
        i0.ɵɵtextInterpolate1("Your name is ", ctx.name, "");
    }
}
Enter fullscreen mode Exit fullscreen mode

This function receives two arguments: rf (render flags) and ctx (context). Angular invokes this function whenever the template needs to either:

  1. Be initially rendered.
  2. Have its content refreshed after a change.

The "render flag" (rf) is passed with different values depending on which of these two scenarios is happening. This gives Angular precise control over the update process.

As of Angular 15, only two flags are officially defined:

// Source code from @angular/core
// angular/packages/core/src/render3/interfaces/definition.ts

export const enum RenderFlags {
  /* Whether to run the creation block (e.g. create elements and directives) */
  Create = 0b01,

  /* Whether to run the update block (e.g. refresh bindings) */
  Update = 0b10
}
Enter fullscreen mode Exit fullscreen mode

The first flag, Create, is passed to the template function on the initial render. This triggers the first if block. Let's isolate that section:

i0.ɵɵelementStart(0, "span");
i0.ɵɵtext(1);
i0.ɵɵelementEnd();
Enter fullscreen mode Exit fullscreen mode

In essence, Angular is instructing the browser to "create a span element and prepare it to hold text content."

Once this creation step is complete, Angular passes the Update flag through the template function:

i0.ɵɵadvance(1);
i0.ɵɵtextInterpolate1("Your name is ", ctx.name, "");
Enter fullscreen mode Exit fullscreen mode

This time, the instruction is to interpolate the string "Your name is Alex" using the property from ctx.name and insert it into the element's text node.

By separating the template function into these two distinct phases, controlled by the flags, Angular can create the span element once during the initial render and then simply update its text content on subsequent renders. This avoids having to re-initialize the span element every time the text within it changes.

How Does Angular Actually Run the Template Compiler?

As discussed earlier, Angular invokes the render function with two distinct render flags: Create and Update.

Rather than taking this at face value, let's examine Angular's source code to confirm.

Within @angular/core, there's a function named renderComponent:

// Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts
function renderComponent(hostLView: LView, componentHostIdx: number) {
  ngDevMode && assertEqual(isCreationMode(hostLView), true, 'Should be run in creation mode');
  const componentView = getComponentLViewByIndex(componentHostIdx, hostLView);
  const componentTView = componentView[TVIEW];
  syncViewWithBlueprint(componentTView, componentView);
  renderView(componentTView, componentView, componentView[CONTEXT]);
}
Enter fullscreen mode Exit fullscreen mode

In broad terms, this function retrieves a component's View (a core internal concept in Angular for HTML element references that I've covered previously) and proceeds to render it through Angular's renderView function.

Now let's inspect that renderView function:

// Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts
export function renderView<T>(tView: TView, lView: LView<T>, context: T): void {
  ngDevMode && assertEqual(isCreationMode(lView), true, 'Should be run in creation mode');
  enterView(lView);
  try {
    const viewQuery = tView.viewQuery;
    if (viewQuery !== null) {
      executeViewQueryFn<T>(RenderFlags.Create, viewQuery, context);
    }

    // Execute a template associated with this view, if it exists. A template function might not be
    // defined for the root component views.
    const templateFn = tView.template;
    if (templateFn !== null) {
      executeTemplate<T>(tView, lView, templateFn, RenderFlags.Create, context);
    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

Observe that the executeTemplate function is invoked with the RenderFlags.Create flag, matching our earlier description.

There's no hidden complexity within the executeTemplate function itself; the entire implementation is shown here:

// Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts

function executeTemplate<T>(
    tView: TView, lView: LView<T>, templateFn: ComponentTemplate<T>, rf: RenderFlags, context: T) {
  const prevSelectedIndex = getSelectedIndex();
  const isUpdatePhase = rf & RenderFlags.Update;
  try {
    setSelectedIndex(-1);
    if (isUpdatePhase && lView.length > HEADER_OFFSET) {
      // When we're updating, inherently select 0 so we don't
      // have to generate that instruction for most update blocks.
      selectIndexInternal(tView, lView, HEADER_OFFSET, !!ngDevMode && isInCheckNoChangesMode());
    }

    const preHookType =
        isUpdatePhase ? ProfilerEvent.TemplateUpdateStart : ProfilerEvent.TemplateCreateStart;
    profiler(preHookType, context as unknown as {});
    templateFn(rf, context);
  } finally {
    setSelectedIndex(prevSelectedIndex);

    const postHookType =
        isUpdatePhase ? ProfilerEvent.TemplateUpdateEnd : ProfilerEvent.TemplateCreateEnd;
    profiler(postHookType, context as unknown as {});
  }
}
Enter fullscreen mode Exit fullscreen mode

If we strip away some complexities to focus on the essentials, we arrive at this:

// Simplified Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts

function executeTemplate<T>(
    tView: TView, lView: LView<T>, templateFn: ComponentTemplate<T>, rf: RenderFlags, context: T) {

    // ...

        templateFn(rf, context);

    // ...
}
Enter fullscreen mode Exit fullscreen mode

With this simplified view, we can see that executing:

// Simplified Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts

const templateFn = tView.template;

// ...

executeTemplate<T>(tView, lView, templateFn, RenderFlags.Create, context);
Enter fullscreen mode Exit fullscreen mode

translates to invoking the component's template function with a RenderFlags.Create argument along with the function's context.

Handling Component Updates

In the same way that the template function clearly gets called with RenderFlags.Create, there's an equally straightforward example of it being invoked with RenderFlags.Update.

The Update flag comes from Angular's refreshView function, triggered whenever a component is due for an update.

// Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts

export function refreshView<T>(
    tView: TView, lView: LView, templateFn: ComponentTemplate<{}>|null, context: T) {
  ngDevMode && assertEqual(isCreationMode(lView), false, 'Should be run in update mode');
  const flags = lView[FLAGS];
  if ((flags & LViewFlags.Destroyed) === LViewFlags.Destroyed) return;
  enterView(lView);
  // Check no changes mode is a dev only mode used to verify that bindings have not changed
  // since they were assigned. We do not want to execute lifecycle hooks in that mode.
  const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode();
  try {
    resetPreOrderHookFlags(lView);

    setBindingIndex(tView.bindingStartIndex);
    if (templateFn !== null) {
      executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

That final line is becoming familiar—the executeTemplate makes another appearance, this time with RenderFlags.Update!

While seeing this clearly is satisfying, it raises a significant question: How does the component determine it's time for an update?

Inside Angular's Change Detection: When refreshView Gets Called

To tackle "how Angular knows a component needs updating", let's trace the call stack leading up to the refreshView function.

Moving up one level, we find that refreshView is invoked from within a function called detectChangesInternal:

// Angular 15 source code
// angular/packages/core/src/render3/instructions/shared.ts

export function detectChangesInternal<T>(
    tView: TView, lView: LView, context: T, notifyErrorHandler = true) {
  const rendererFactory = lView[RENDERER_FACTORY];

  // Check no changes mode is a dev only mode used to verify that bindings have not changed
  // since they were assigned. We do not want to invoke renderer factory functions in that mode
  // to avoid any possible side-effects.
  const checkNoChangesMode = !!ngDevMode && isInCheckNoChangesMode();

  if (!checkNoChangesMode && rendererFactory.begin) rendererFactory.begin();
  try { 
      refreshView(tView, lView, tView.template, context);
   } catch (error) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

This is then called from the publicly exposed @angular/core detectChanges function:

// Angular 15 source code
// angular/packages/core/src/render3/view_ref.ts
detectChanges(): void {
    detectChangesInternal(this._lView[TVIEW], this._lView, this.context as unknown as {});
}
Enter fullscreen mode Exit fullscreen mode

Manually Invoking Change Detection

Let's employ Angular's NgZone's runOutsideOfAngular to run code outside of Angular's standard change detection cycle:

import { ApplicationRef, Component, NgZone } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  constructor(private ngZone: NgZone) {}

  name = '';
  changeName() {
    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        this.name = 'Angular';
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

If NgZone is unfamiliar, rest assured we'll cover it in full detail later in this article. 😄

When you press the <button> for the first time, you'll notice it doesn't display Hello Angular as expected. The greeting only appears after subsequent clicks.

This behavior is deliberate—we've instructed our code to bypass Angular's normal change detection. To fix this, we can trigger detectChanges manually ourselves:

import {
  ChangeDetectorRef,
  Component,
  NgZone,
} from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  constructor(private ngZone: NgZone, private cd: ChangeDetectorRef) {}

  name = '';
  changeName() {
    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        this.name = 'Angular';
        this.cd.detectChanges();
      });
    });
  }
}   
Enter fullscreen mode Exit fullscreen mode

This detectChanges call triggers the refreshView function we examined earlier, which subsequently calls executeTemplate with RenderFlags.Update. That flag is then passed to the component's template function, as generated by NGC.

What triggers detectChanges in Angular?

If you've confirmed that detectChanges is indeed responsible for instructing your component to update, the next logical question is: what invokes detectChanges?

The answer lies in a global object Angular creates when your application starts, whether through bootstrapModule or bootstrapApplication. This object, known as ApplicationRef, holds the methods and metadata Angular requires to manage your entire application.

Inside this ApplicationRef, there is a method named tick. Essentially, Angular invokes this method after it detects user interaction with the app, once all activity has settled down.

The reason Angular calls tick is straightforward: when a user interacts with any part of the page, the application could require a re-render to reflect the changes resulting from that interaction.

How does this relate to detectChanges?

Here's the crucial detail: ApplicationRef.tick directly invokes detectChanges.

// Angular 15 source code
// angular/packages/core/src/application_ref.ts
tick(): void {
  NG_DEV_MODE && this.warnIfDestroyed();
  if (this._runningTick) {
    throw new RuntimeError(
        RuntimeErrorCode.RECURSIVE_APPLICATION_REF_TICK,
        ngDevMode && 'ApplicationRef.tick is called recursively');
  }

  try {
    this._runningTick = true;
    for (let view of this._views) {
      view.detectChanges();
    }
  // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Consequently, swapping detectChanges for ApplicationRef.tick produces the same outcome we achieved earlier:

import { ApplicationRef, Component, NgZone } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  constructor(private ngZone: NgZone, private appRef: ApplicationRef) {}

  name = '';
  changeName() {
    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        this.name = 'Angular';
        this.appRef.tick();
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Zone.js fundamentals: a brief exploration

To go further into Angular's internals, I have to introduce a library Google developed for Angular: Zone.js.

The core concept of Zone.js is that you can establish a "context" in which your code executes. This context lets you monitor what's running, execute custom error handling logic, and perform other operations.

The following minimal example illustrates what Zone.js can do:

import "zone.js";

const newZone = Zone.current.fork({
  name: 'error',
  onHandleError: function (_, __, ___, error) {
    console.log(error.message);
  },
});

newZone.run(() => {
  setTimeout(() => {
    throw new Error('This is an error thrown in a setTimeout');
  });
});
Enter fullscreen mode Exit fullscreen mode

Here, Zone.current is a global variable defined when you first import zone.js.

We then "fork" the current "zone" to establish our own "execution context" or "zone".

This new zone includes an error handler (onHandleError) that, in our example, uses console.log to output the error message instead of the browser's default console.error.

Next, we invoke a "task" by providing a function to newZone. Even though the Error originates inside a setTimeout, our onHandleError still catches it.

How Angular integrates Zone.js

Now that the basics of Zone.js are clear, let's examine how Angular leverages it.

Angular maintains a "Zone" called "NgZone" within ApplicationRef to keep track of the application's execution context.

The setup code for "NgZone" is too intricate to present in its entirety here, but you can conceptually view "NgZone" as follows:

// This is not how ngZone is really defined,
// it's just a really rough approximation

const ngZone = Zone.current.fork({
    // ... Setup the ngZone here
})
Enter fullscreen mode Exit fullscreen mode

This "NgZone" is subsequently passed to the ApplicationRef's constructor in this manner:

// Angular 15 source code
// angular/packages/core/src/application_ref.ts

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

You might spot that this _zone is subscribed to run this.tick() (i.e., ApplicationRef.tick()) once the microtask queue empties.

This is the mechanism that makes Angular's detectChanges appear to fire on its own. Skeptical? Let's disable Zone.js in an Angular app and observe whether change detection behaves as expected.

Disabling Zone.js in Angular

To turn off Zone.js in an Angular application, you simply pass {ngZone: 'noop'} during bootstrapping:

// main.ts
platformBrowserDynamic()
   .bootstrapModule(
       AppModule, { ngZone: 'noop' })
   .catch(err => console.log(err));
Enter fullscreen mode Exit fullscreen mode

With Zone.js disabled, clicking the button repeatedly in the example below will never trigger change detection:

// This does not work with a "noop" NgZone
import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  name = '';
  changeName() {
    setTimeout(() => {
      this.name = 'Angular';
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

You have two options to restore functionality: manually invoke change detection using either appRef.tick() or cd.detectChanges:

// This works with a "noop" NgZone
import { ApplicationRef, Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  constructor(private appRef: ApplicationRef) {}
  name = '';
  changeName() {
    setTimeout(() => {
      this.name = 'Angular';
      // Developer experience suffers since we MUST call this every time we change state
      this.appRef.tick();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can re-enable Zone.js:

// main.ts
// Re-enable NgZone
platformBrowserDynamic()
   .bootstrapModule(
       AppModule)
   .catch(err => console.log(err));
Enter fullscreen mode Exit fullscreen mode
// This works again now that we re-enabled Zone.js
@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  name = '';
  changeName() {
    setTimeout(() => {
      this.name = 'Angular';
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

So, Angular works without Zone.js, but the developer experience suffers—noteworthy.

Yet, we aren't explicitly invoking ngZone.run inside changeName; how does it engage Zone.js to trigger Angular's tick?

Our changeName method can trigger Angular's tick because of a technique called a "monkey-patch."

Zone.js patches APIs for Angular

A zone within Zone.js can only see code running within its context. Consider the earlier minimal Zone.js example:

newZone.run(() => {
  setTimeout(() => {
    throw new Error('This is an error thrown in a setTimeout');
  });
});
Enter fullscreen mode Exit fullscreen mode

Let's consider this code on a conceptual level:

setTimeout schedules a timer within the JavaScript engine. The callback runs after the timer expires. How does Zone.js recognize the throw new Error as part of its context when the engine calls the callback "externally"?

The answer is: by default, it doesn't. A trivial implementation of Zone.js would not correctly manage setTimeout or other asynchronous APIs.

Unfortunately, our applications frequently depend on asynchronous operations. Fortunately, Zone.js implementation is not trivial—it patches the async APIs your app may use, redirecting task execution back into its "context."

While the exact details are intricate, essentially Zone.js inserts additional code after async tasks finish, notifying Zone.js to resume execution where it left off.

That process might look like this:

// This is not how Zone.js really works,
// this is a trivial implementation to demonstrate how Zone.js patches async APIs
const originalSetTimeout = setTimeout;

setTimeout = (callback, delay, ...args) => {
    const context = this;

    return originalSetTimeout(() => {
        callback.apply(context, args);
        Zone.current.run();
    }, delay);
};
Enter fullscreen mode Exit fullscreen mode

Here, we replace the global setTimeout with our own version, calling Zone.current.run(); after the asynchronous operation completes.

This approach is conceptually close to how Zone.js patches global async APIs. For instance, this snippet tells Zone.js to patch setTimeout:

// Zone.js source code
// angular/packages/zone.js/lib/browser/browser.ts
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');
});
Enter fullscreen mode Exit fullscreen mode

Observe that it patches setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, and clearImmediate in one go.

So, when our Angular component uses setTimeout:

// This does not work with a "noop" NgZone
import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  name = '';
  changeName() {
    setTimeout(() => {
      this.name = 'Angular';
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Zone.js patches more APIs than you might think

Wait a second... If Zone.js is responsible for triggering ApplicationRef.tick, how come change detection appears to run even when no asynchronous API is obviously being used?

That's a fair question! Consider the Angular component below—it makes no use of setTimeout, and yet it still triggers ApplicationRef.tick (assuming NgZone is enabled):

import { ApplicationRef, Component, NgZone } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  name = '';
  changeName() {
    this.name = 'Angular';
  }
}
Enter fullscreen mode Exit fullscreen mode

What's going on here?

The answer lies in the literal meaning of "asynchronous." It doesn't just refer to timers—it encompasses any operation that is non-blocking. That goes beyond output; it also includes user input.

You may suspect that this means Zone.js patches the (click) listener, and you'd be correct!

If we imagine how an HTML element would be created in plain JavaScript without a framework, it might look something like this:

const el = document.createElement('button');
el.addEventListener('click', () => console.log("I have been pressed"));
Enter fullscreen mode Exit fullscreen mode

Well, behind the scenes, the NGC compiler does exactly the same thing with our (click) template syntax!

Now that we understand that the component's template function calls addEventListener, let's explore how Zone.js patches this API.

To grasp how Zone.js patches addEventListener, we first need to know where the browser actually implements this function. You might assume it's a method on the browser's HTMLElement built-in type, but it actually lives on the browser's built-in EventTarget type. HTMLElement only inherits this method from EventTarget.

This is precisely why, in Zone.js's source code, it patches EventTarget directly:

// Zone.js source code
// angular/packages/zone.js/lib/browser/browser.ts

Zone.__load_patch('EventTarget', (global: any, Zone: ZoneType, api: _ZonePrivate) => {
  patchEvent(global, api);
  eventTargetPatch(global, api);
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Looking into eventTargetPatch, we can even find where it patches ADD_EVENT_LISTENER specifically:

export function patchEventTarget(
    _global: any, api: _ZonePrivate, apis: any[], patchOptions?: PatchEventTargetOptions) {
  const ADD_EVENT_LISTENER = (patchOptions && patchOptions.add) || ADD_EVENT_LISTENER_STR;
  const REMOVE_EVENT_LISTENER = (patchOptions && patchOptions.rm) || REMOVE_EVENT_LISTENER_STR;

   // ...
}
Enter fullscreen mode Exit fullscreen mode

This means that when the user clicks the button in the following example:

import { ApplicationRef, Component, NgZone } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <h1>Hello {{name}}</h1>
  <button (click)="changeName()">Change Name</button>
  `,
})
export class AppComponent {
  name = '';
  changeName() {
    this.name = 'Angular';
  }
}
Enter fullscreen mode Exit fullscreen mode

It will:

  • Execute the click changeName function
  • Execute Zone.js's patched addEventListener function
  • Trigger the onMicrotaskEmpty subscription
  • Trigger tick on the ApplicationRef

This explains why even in our runOutsideOfAngular example, clicking the button multiple times displays live data. The event is bound, and as a result, the component is re-rendered via App.tick once the bound event fires.

Demonstration of Event Patching

As an interesting side note, even an empty function triggers change detection (although it won't cause a re-render since no data changes). We can confirm this by subscribing to onMicrotaskEmpty ourselves:

import { Component, NgZone } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
    <button (click)="test()">Test</button>
  `,
})
export class AppComponent {
  constructor(private zone: NgZone) {
    zone.onMicrotaskEmpty.subscribe({
      next: () => {
        console.log('EMPTY MICROTASK, RUN TICK');
      },
    });
  }

  // This is empty but will still cause an `ApplicationRef.tick` if NgZone is enabled
  test() {}
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hopefully, this has given you valuable insight into how Angular's change detection works under the hood, both with and without Zone.js. Armed with this understanding, you should be able to optimize your applications by identifying patterns where your code might be triggering excessive re-renders.

If you enjoyed this article, be sure to check out my upcoming book series, The Framework Field Guide, where I teach React, Angular, and Vue from the fundamentals all the way to advanced deep-dives like this one. (Psst, all three books are free!)