Angular HostListeners - Watch Out for This!

Be careful of how you use the Angular HostListener decorator. Listening to events on a wide scope can cause performance issues.

Quite recently, I used the Angular HostListener in one of my demo projects. This looks so simple and easy to use — what could possibly go wrong?

As it turns out, it can impact the performance of your application, if you are careless.

In this article, we are going to demonstrate how this could happen along with a few ways to deal with it.

Let’s get started!

The Problem

In Angular, HostListener is a decorator that we can use to create an event listener for a component.

For example, we can create an event listener that listens to any click event on a component.

@Component({
  // …
})
export class TileComponent {
  @HostListener('click', ['$event'])
  public onClick(event: MouseEvent): void {
    // Do something when the component is clicked
  }
}

So far, so good — this is a simple and harmless snippet of code.

Yet the HostListener decorator can cause performance issues if we are not careful.

The first argument of the HostListener is a string that tells Angular what event it should listen to and on what scope.

For example:

  • @HostListener('click') listens to any click on the component

  • @HostListener('window:click') listens to any click on the window object

  • @HostListener('document:keydown') listens to any key-down event on the document

and so on.

The global target names that can be used to prefix an event name are window:, document:, and body:.

This is where things can go wrong. Because now we are not just listening to events on the component. We are listening to events on the entire window, document, or body respectively.

Every time we click somewhere in our application, a change detection will be triggered.

Now imagine such a component appearing multiple times on a page — let’s say 12 times.

Change detection will be triggered 12 times each time the user clicks anywhere on that page of the application.

Don’t believe me? Let’s see this in action!

Below we can see the starting screen of our demo application. The user can type the number of tiles they want to be rendered.

All tiles except one appear flipped down — more on that later.

Each tile has a HostListener with a scope of document:click. A few clicks on the screen and 😲!

CPU usage reaches 100% — look at those peaks.

Yikes!

The Solution

The simplest and easiest way to deal with this is to use a narrower scope ('click') instead of a wider scope (e.g. 'document:click').

But sometimes we may want to use a wider scope. A typical example is when we want to hide something — like a dropdown, tooltip, or popover — when we click outside of it.

This is the scenario we will simulate in our demo. The faced-up tile represents the opened dropdown. We want to flip it down by clicking anywhere on the screen.

The problem is that even after flipping down the tile, change detection will still be triggered for each tile, on each click event!

What can we do?

We could use the OnPush change detection strategy on all tiles. We know that this will work in our case.

That’s some serious improvement right there. But in real applications, we won’t be able to mark all components as OnPush.

Can we do better?

This is ideal for when change detection is redundant. In our case, we do need to run change detection when the tile flips down.

@Component({
  // …
})
export class TileComponent {
  @Input() isFacedUp: boolean;

  constructor(private ngZone: NgZone) { }
  
  @HostListener('document:click', ['$event'])
  public onClick(event: MouseEvent): void {
    this.ngZone.runOutsideAngular(() => {
      // Do something when the component is clicked.
      // It won't trigger change detection!
      if(this.isFacedUp){
        this.ngZone.run(() => {
          // Do something that requires change detection
          this.isFacedUp = false;
        });
      }
    });
  }
}

This solution works, but what if we have multiple HostListener decorators across different components of the application?

Can we reduce the boilerplate somehow?

We could extend the EventManager provided by Angular and patch events. But then how do we know which event to patch?

We can use a suffix (say 'outer-zone') on the events of interest and a delimiter (say '#') for splitting it.

const DELIMITER= '#';
const SUFFIX = 'outer-zone';
export const EVENT_SUFFIX = DELIMITER + SUFFIX;

@Injectable()
export class CustomEventManager extends EventManager {
  constructor(
    @Inject(EVENT_MANAGER_PLUGINS) plugins: any[],
    private ngZone: NgZone
  ) {
    super(plugins, ngZone);
  }
  override addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: Function
  ): Function {
    if (eventName.endsWith(SUFFIX)) {
      eventName = eventName.split(DELIMITER)[0];
      return this.ngZone.runOutsideAngular(() =>
        super.addEventListener(element, eventName, handler)
      );
    }
    return super.addEventListener(element, eventName, handler);
  }
}

We override the addEventListener method and check if the event name ends with the suffix. If it does, we extract the original event name by splitting it with the delimiter. Finally, we run the original event outside Angular.

Now we simply need to add the delimiter and suffix on each HostListener as shown below.

@Component({
  // …
})
export class TileComponent {
  // ...

  @HostListener('document:click' + EVENT_SUFFIX, ['$event'])
  public onClick(event: MouseEvent): void {
    // Runs outside Angular
    if(this.isFacedUp){
      this.ngZone.run(() => {
        // Do something that requires change detection
        this.isFacedUp = false;
      });
    }
  }
}

Let’s check this in action. By clicking the “Optimized?” checkbox, we replace the tiles with new ones that use suffixed event names.

Observe the difference in CPU usage. Also, change detection is only triggered once when it’s actually needed.

You can find the source code of the demo application in this GitHub repository.

Don’t forget to subscribe to my newsletter for more content like this!

Conclusion

Every tool can be useful or harmful — it all comes down to how you use it. The Angular HostListener decorator is no different. One should be aware of the performance issues it can cause.

Listening to events on wider scopes — such as the document or window — can trigger change detection way too many times, which in turn can degrade the performance of your application.

Thank you for reading!