Running event listeners outside of the NgZone

NgZone notifies Angular when to perform the change detection process (e.g. a DOM event with bound listener is one of the triggerers). However, if in response to an event you directly manipulate the DOM or simply perform an action which does not require bindings update, the process is redundant.

Running event listeners outside of the NgZone

Have you ever wondered how Angular knows that it has to update bindings in templates? In a nutshell, the tailored version of the zone.js library (NgZone) takes care of notifying Angular when to perform the change detection process. A DOM event for which a listener is registered is one of the triggerers. Although Angular is a highly performant framework, you can limit the number of components that undergo the change detection process using a low-hanging fruit, namely the OnPush strategy. However, there are scenarios when in response to an event you directly manipulate the DOM or perform other actions which do not require running the change detection process. In this blog post, I will cover how to properly register event listeners outside of the NgZone.

Running code in the NgZone

Let's assume that upon the button click, you want to invoke the console.log method:

<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

If you click the button, both the bound event listener is invoked and the change detection process is performed. In a real-world scenario, instead of calling the console.log you could make an action not requiring bindings update.

Incorrect usage of the runOutsideAngular method

Although the method in question allows you to opt-out of the change detection process, it has to contain the code registering an event listener. Therefore, the following solution which simply run the callback outside of the NgZone will not prevent from performing the change detection process:

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

Running code outside of the NgZone - using ViewChild

You can grab the reference to the DOM node using the ViewChild decorator and add an event listener in one of the following ways:

<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

As a result, clicking the button will not trigger the change detection process.

Running code outside of the NgZone - using directive

Although the solution from the previous paragraph works well, it's a little bit verbose. You can encapsulate the logic in an attribute directive, which provides easy access to the underlying DOM element with the aid of the dependency injection (ElementRef token). Next, you can add an event listener outside of the NgZone and emit an event when appropriate:

<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 hook is a perfect place to remove the event listener so that there are no memory leaks. Note that by using the name alias for the event emitter, you can apply the directive and register the event's handler in a single statement.

Running code outside of the NgZone - using Event Manager Plugin

The solution based on the directive has a drawback that you cannot configure it with an event type. Fortunately, Angular allows you to provide a custom Event Manager Plugin. In short, you take control of adding a listener for an event whose name matches the given predicate function (the supports method). If there is a match, the addEventListener method is invoked where you can handle the task. The two methods belong to the user-defined service which is registered as a provider for 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

Beware of third-party code

Let’s assume that you want to add tooltips to your application. There’s no point in reinventing the wheel, therefore a reasonable approach is to make use of a third-party library (e.g. tippy.js):

<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 great, however the change detection process is performed each time you hover over the button element. Undoubtedly, it’s redundant, since the tooltip element is added to the DOM in an imperative way using the native API (no need to update bindings in template). Luckily, you can opt-out of triggering the change detection process by invoking 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!"
    });
  }
}

Conclusions

If you find yourself in a situation when in response to a DOM event you perform a task not requiring binding updates, you can improve your application performance by not triggering unnecessary change detection run. Care must be taken to properly register an event listener outside of the NgZone. The solution based on custom Event Manager Plugin is the most elegant and reusable way to accomplish the goal. If you use a third-party solution which alters the DOM, it's worth to consider running its initialization code outside of the NgZone.

Live example:

I hope you liked the post and learned something new.