How Angular detects changes

Have you ever asked yourself what mechanism lets Angular know that it must refresh template bindings? Essentially, the custom build of the zone.js library used by Angular (commonly known as NgZone) is responsible for signaling the framework to launch the change detection cycle. One of the events that can set this process in motion is any DOM interaction that has a registered handler. While Angular already performs admirably, you can easily trim the set of components being checked when using the OnPush strategy. Still, there are cases where an event triggers only a direct DOM update or some other task that has nothing to do with data-bound templates. This article explains how to attach event handlers outside of the NgZone in such scenarios.

Executing logic inside the NgZone

Suppose that clicking a button should merely log a message to the console:

<h2>Click handler in NgZone</h2>
<button class="btn btn-primary" (click)="onClick()">
  Click me!
</button>
import { AfterViewChecked, Component } from "@angular/core";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent implements AfterViewChecked {

  onClick() {
    console.log("onClick");
  }

  ngAfterViewChecked() {
    console.log("CD performed");
  }
}

// console output: onClick, CD performed

When you trigger the click, both the attached listener and the change detection process run. In practice, instead of console.log, you might be performing an operation that does not require any binding updates.

The wrong way to use runOutsideAngular

The runOutsideAngular method lets you leave the Angular zone, but the key detail is that the listener registration itself must be placed inside of it. As a result, the following approach, which just executes the handler out of the zone, will not stop change detection from being triggered:

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

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent implements AfterViewChecked {

 constructor(private readonly zone: NgZone) {}

  onClick() {
    this.zone.runOutsideAngular(() => {
      console.log("onClick");
    });
  }

  ngAfterViewChecked() {
    console.log("CD performed");
  }
}

// console output: onClick, CD performed

Leaving the zone with ViewChild

Through the ViewChild decorator, you can get a hold of the DOM element and attach a listener using either of these techniques:

<h2>Click handler outside NgZone</h2>
<button #btn class="btn btn-primary">
  Click me!
</button>
import {
  AfterViewChecked,
  AfterViewInit,
  Component,
  ElementRef,
  NgZone,
  Renderer2,
  ViewChild
} from "@angular/core";
import { fromEvent } from "rxjs";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent implements AfterViewInit, AfterViewChecked {
  @ViewChild("btn") btnEl: ElementRef<HTMLButtonElement>;

  constructor(
    private readonly zone: NgZone,
    private readonly renderer: Renderer2
  ) {}

  onClick() {
    console.log("onClick");
  }

  ngAfterViewInit() {
    this.setupClickListener();
  }

  ngAfterViewChecked() {
    console.log("CD performed");
  }

  private setupClickListener() {
    this.zone.runOutsideAngular(() => {
      this.setupClickListenerViaNativeAPI();
      // this.setupClickListenerViaRenderer();
      // this.setupClickListenerViaRxJS();
    });
  }

  private setupClickListenerViaNativeAPI() {
    this.btnEl.nativeElement.addEventListener("click", () => {
      console.log("onClick");
    });
  }

  private setupClickListenerViaRenderer() {
    this.renderer.listen(this.btnEl.nativeElement, "click", () => {
      console.log("onClick");
    });
  }

  private setupClickListenerViaRxJS() {
    fromEvent(this.btnEl.nativeElement, "click").subscribe(() => {
      console.log("onClick");
    });
  }
}

// console output: onClick

After that, clicking the button will no longer cause change detection to run.

Leaving the zone using a directive

The ViewChild approach works, but it is rather wordy. You can wrap the logic in an attribute directive that gets the underlying element through dependency injection (the ElementRef token). Then, an event handler is registered inside the zone-agnostic context, and an event is emitted when needed:

<h2>Click handler outside NgZone</h2>
<button class="btn btn-primary" (click.zoneless)="onClick()">
  Click me!
</button>
import {
  Directive,
  ElementRef,
  EventEmitter,
  NgZone,
  OnDestroy,
  OnInit,
  Output,
  Renderer2
} from "@angular/core";
@Directive({
  selector: "[click.zoneless]"
})
export class ClickZonelessDirective implements OnInit, OnDestroy {
  @Output("click.zoneless") clickZoneless = new EventEmitter<MouseEvent>();

  private teardownLogicFn: Function;

  constructor(
    private readonly zone: NgZone,
    private readonly el: ElementRef,
    private readonly renderer: Renderer2
  ) {}

  ngOnInit() {
    this.zone.runOutsideAngular(() => {
      this.setupClickListener();
    });
  }

  ngOnDestroy() {
    this.teardownLogicFn();
  }

  private setupClickListener() {
    this.teardownLogicFn = this.renderer.listen(
      this.el.nativeElement,
      "click",
      (event: MouseEvent) => {
        this.clickZoneless.emit(event);
      }
    );
  }
}

// console output: onClick

The OnDestroy lifecycle hook is the right spot to remove the listener and avoid leaks. Also, using an alias for the event emitter allows you to combine the directive with the event binding in one line.

Leaving the zone with an Event Manager Plugin

The directive-based solution has a limitation: you cannot pass an event type to it. Angularyou can, however, provide a custom Event Manager Plugin that behaves as you wish. In practice, you assume responsibility for attaching a listener when the event name matches the predicate in the supports method. On a match, your addEventListener implementation handles the rest. Both of these methods belong to a service that is registered with the EVENT_MANAGER_PLUGINS token:

<h2>Click handler outside NgZone</h2>
<button class="btn btn-primary" (click.zoneless)="onClick()">
  Click me!
</button>
import { Injectable } from "@angular/core";
import { EventManager } from "@angular/platform-browser";

@Injectable()
export class ZonelessEventPluginService {
  manager: EventManager;

  supports(eventName: string): boolean {
    return eventName.endsWith(".zoneless");
  }

  addEventListener(
    element: HTMLElement,
    eventName: string,
    originalHandler: EventListener
  ): Function {
    const [nativeEventName] = eventName.split(".");

    this.manager.getZone().runOutsideAngular(() => {
      element.addEventListener(nativeEventName, originalHandler);
    });

    return () => element.removeEventListener(nativeEventName, originalHandler);
  }
}
import { NgModule } from "@angular/core";
import {
  BrowserModule,
  EVENT_MANAGER_PLUGINS
} from "@angular/platform-browser";

import { AppComponent } from "./app.component";
import { ClickZonelessDirective } from "./click-zoneless.directive";
import { ZonelessEventPluginService } from "./zoneless-event-plugin.service";

@NgModule({
  imports: [BrowserModule],
  declarations: [
    AppComponent,
    // ClickZonelessDirective
  ],
  bootstrap: [AppComponent],
  providers: [
    {
      provide: EVENT_MANAGER_PLUGINS,
      useClass: ZonelessEventPluginService,
      multi: true
    }
  ]
})
export class AppModule {}

// console output: onClick

Watch out for libraries

Consider a situation where you'd like to add tooltips to a page. You don't need to build this from scratch, so a library like tippy.js seems like a solid choice:

<h2>3rd party lib initialized in NgZone</h2>
<button appTooltip class="btn btn-danger">Hover me!</button>
import { Directive, ElementRef, OnInit } from "@angular/core";
import tippy from "tippy.js";

@Directive({
  selector: "[appTooltip]"
})
export class TooltipDirective implements OnInit {
  constructor(private readonly el: ElementRef) {}

  ngOnInit() {
    this.setupTooltip();
  }

  private setupTooltip() {
    tippy(this.el.nativeElement, {
      content: "Bazinga!"
    });
  }
}

It works fine, but change detection runs on every mouse hover over the button. That's clearly unnecessary, as the tooltip is inserted into the DOM directly through the native API and no template bindings are affected. Fortunately, you can skip the change detection process by calling the initialization code outside of the NgZone:

<h2>3rd party lib initialized outside NgZone</h2>
<button appTooltip class="btn btn-danger">Hover me!</button>
import { Directive, ElementRef, NgZone, OnInit } from "@angular/core";
import tippy from "tippy.js";

@Directive({
  selector: "[appTooltip]"
})
export class TooltipDirective implements OnInit {
  constructor(private readonly zone: NgZone, private readonly el: ElementRef) {}

  ngOnInit() {
    this.zone.runOutsideAngular(() => {
      this.setupTooltip();
    });
  }

  private setupTooltip() {
    tippy(this.el.nativeElement, {
      content: "Bazinga!"
    });
  }
}

Summary

Whenever an event leads to work that doesn't involve updating bindings, you can boost your app's performance by avoiding extra change detection cycles. It's essential to register the event listener correctly outside of the NgZone. The custom Event Manager Plugin offers the most clean and reusable approach. And when using a third-party tool that manipulates the DOM, consider running its setup code away from the zone as well.

Live example:

Hope you found the article helpful.