Serving Industries Worldwide

Innovative Ways - Satisfied Clientele

Adding Event Listeners outside of the NgZone


Listening is fun too.

Straighten your back and cherish with coffee - PLAY !

 
 

ListenersoutsideofNgZone

If we're familiar with the Angular framework, we'll know that by default, any asynchronous event triggers the change detection process. In certain situations, we don't even have to worry about it; it just works as expected. However, in some cases, running the change detection process too frequently can lead to poor runtime efficiency.

Table of Content

Execution of code in the NgZone

Assume that we want to call the console.log method when we click the button:

Click handler in NgZone

  Click me!
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 we click the button, both the bound event listener and the change detection process are triggered. In a real-world scenario, instead of calling console.log, we could perform an action that does not require bindings to be updated.

Incorrect usage of the runOutsideAngular method

Although the method in question allows us to opt-out of the change detection process, it must provide code to register an event listener. As a result, the following solution, which simply runs the callback outside of the NgZone, will not prevent the change detection process from being performed:\

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

Execution of code outside of the NgZone - using ViewChild

We may use the ViewChild decorator to get a reference to the DOM node and add an event listener in one of the following ways:



Click handler outside NgZone

  Click me! 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;   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 won't trigger the change detection process.

Execution of code outside of the NgZone - using directive

While the previous paragraph's solution works well, it is a little verbose. we can encapsulate the logic in an attribute directive, which allows dependency injection to provide easy access to the underlying DOM element (ElementRef token). Then, outside of the NgZone, we can add an event listener and emit an event when it's appropriate:

Click handler outside NgZone

  Click me! 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();   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 ideal for removing the event listener and preventing memory leaks. We may apply the directive and register the event's handler in a single statement if we use the name alias for the event emitter.

Execution of code outside of the NgZone - using Event Manager Plugin

The directive-based approach has the disadvantage of not being able to be configured for an event type. Thankfully, Angular allows us to build our Event Manager Plugin. In other words, we take control of adding a listener for an event whose name corresponds to the predicate function (the supports method). If a match is found, the addEventListener method is called, allowing us to handle the job. The two methods are part of the user-defined service that is registered as an EVENT MANAGER PLUGINS token provider:

Click handler outside NgZone

  Click me! 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

One Stop Solution for Angular Software Development ?

Enquire Today.


Be Afraid of third-party Code

3rd party lib initialized in NgZone

Hover me! 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 perfectly, but the change detection process is repeated every time we hover over the button element. It is undeniably redundant because the tooltip element is imperatively added to the DOM using the native API (no need to update bindings in template). Fortunately, by calling the initialization code from outside the NgZone, we can avoid triggering the change detection process:

Fortunately, by calling the initialization code from outside the NgZone, we can avoid triggering the change detection process:

3rd party lib initialized outside NgZone

Hover me! 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!"     });   } }

Conclusion

If we find ourselves in a situation where we are performing a task that does not need binding updates in response to a DOM event, we can improve the performance of our application by not triggering an unwanted change detection run Outside of the NgZone, we must be careful when registering an event listener. The most elegant and reusable solution is to use a custom Event Planner Plugin. If we're using a third-party solution that modifies the DOM, we should think about running its initialization code outside of the NgZone.

ListenersoutsideofNgZone

If we're familiar with the Angular framework, we'll know that by default, any asynchronous event triggers the change detection process. In certain situations, we don't even have to worry about it; it just works as expected. However, in some cases, running the change detection process too frequently can lead to poor runtime efficiency.

Table of Content

Execution of code in the NgZone

Assume that we want to call the console.log method when we click the button:

Click handler in NgZone

  Click me!
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 we click the button, both the bound event listener and the change detection process are triggered. In a real-world scenario, instead of calling console.log, we could perform an action that does not require bindings to be updated.

Incorrect usage of the runOutsideAngular method

Although the method in question allows us to opt-out of the change detection process, it must provide code to register an event listener. As a result, the following solution, which simply runs the callback outside of the NgZone, will not prevent the change detection process from being performed:\

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

Execution of code outside of the NgZone - using ViewChild

We may use the ViewChild decorator to get a reference to the DOM node and add an event listener in one of the following ways:



Click handler outside NgZone

  Click me! 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;   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 won't trigger the change detection process.

Execution of code outside of the NgZone - using directive

While the previous paragraph's solution works well, it is a little verbose. we can encapsulate the logic in an attribute directive, which allows dependency injection to provide easy access to the underlying DOM element (ElementRef token). Then, outside of the NgZone, we can add an event listener and emit an event when it's appropriate:

Click handler outside NgZone

  Click me! 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();   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 ideal for removing the event listener and preventing memory leaks. We may apply the directive and register the event's handler in a single statement if we use the name alias for the event emitter.

Execution of code outside of the NgZone - using Event Manager Plugin

The directive-based approach has the disadvantage of not being able to be configured for an event type. Thankfully, Angular allows us to build our Event Manager Plugin. In other words, we take control of adding a listener for an event whose name corresponds to the predicate function (the supports method). If a match is found, the addEventListener method is called, allowing us to handle the job. The two methods are part of the user-defined service that is registered as an EVENT MANAGER PLUGINS token provider:

Click handler outside NgZone

  Click me! 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

One Stop Solution for Angular Software Development ?

Enquire Today.


Be Afraid of third-party Code

3rd party lib initialized in NgZone

Hover me! 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 perfectly, but the change detection process is repeated every time we hover over the button element. It is undeniably redundant because the tooltip element is imperatively added to the DOM using the native API (no need to update bindings in template). Fortunately, by calling the initialization code from outside the NgZone, we can avoid triggering the change detection process:

Fortunately, by calling the initialization code from outside the NgZone, we can avoid triggering the change detection process:

3rd party lib initialized outside NgZone

Hover me! 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!"     });   } }

Conclusion

If we find ourselves in a situation where we are performing a task that does not need binding updates in response to a DOM event, we can improve the performance of our application by not triggering an unwanted change detection run Outside of the NgZone, we must be careful when registering an event listener. The most elegant and reusable solution is to use a custom Event Planner Plugin. If we're using a third-party solution that modifies the DOM, we should think about running its initialization code outside of the NgZone.