Angular provides a handy declarative way to subscribe to an even inside a template using (eventName)="onEventName($event)" syntax. Combine that with ChangeDetectionStrategy.OnPush and you get change detection cycles ran only on events we are interested in. In other words, if we listen to (input) event on an <input> element — we are not going to have change detection ran if user simply clicks on it. The performance is much better this way than with default strategy ChangeDetectionStrategy.Default. Inside directives and components we can also listen to events on host element with @HostListener('eventName') decorator which works the same way.

In my practice, however, there are often cases when handling a particular event is only required if some condition is met. A handler function typically looks like this:

class ComponentWithEventHandler {
  // ...

  onEvent(event: Event) {
    if (!this.condition) {
      return;
    }

    // Handling event ...
  }
}

Even if condition isn’t met and nothing has actually changed — a change detection cycle will be ran anyway. In case of rapid events like scroll or mousemove it could be detrimental to performance.

In a UI components library that I work on a subscription for mousemove inside dropdown menus caused detection checks all the way up components tree on every mouse movement. Monitoring mouse movement was necessary to enable correct behavior but this moment could really use some optimization.

Such cases are especially important for universal UI elements. They could be present on the page in great numbers and application could be really complex and performance sensitive.

You could go around this case by subscribing to an event imperatively without triggering change detection using, say, Observable.fromEvent and then running change detection manually using markForCheck() on ChangeDetectorRef. But that adds a lot of overhead and prevents us from using handy built in Angular tools.

It’s not a secret that Angular allows us to subscribe to so-called pseudo events. We can write (keydown.enter)="onEnter($event)" and event handler (and change detection cycle with it) would only be triggered by pressing Enter key. All other keys will be ignored. In this article we are going to take a look at how we can use the same approach as Angular to optimize event handling. And as a bonus we will add .prevent and .stop modifiers that would prevent default action and stop event propagation automatically.

Event Manager Plugin

To handle events Angular uses EventManager class. It has a set of plugins that extend abstract class EventManagerPlugin and it delegates event subscription and handling to a plugin that supports particular events (picking by event name). Angular has a few plugins built-in, such as HammerJS dedicated one and one responsible for composite events such as keydown.enter. It’s a private implementation and it could change in the future. However 3 years had passed since the creation of this github issue regarding reconsideration of this approach (at the time of writing) and nothing has moved yet, see this issue.

What’s in it for us? Even though these classes are internal and we cannot extend them, an injection token responsible for adding plugins is a part of Angular’s public API. This means we can write our own plugins and extend built-in event handling mechanism with them.

If we take a look at source code for EventManagerPlugin we would see that even if we cannot extend it, it is mostly abstract and creating our own class to satisfy the criteria is rather easy:

export abstract class EventManagerPlugin {
  constructor(private _doc: any) {}
  ...
}

Basically, a plugin must be able to tell if it works with particular event and it should be able to add element event handlers and global event handlers (for body, window, and document). We are going to look for following modifiers: .filter, .prevent, and .stop. To wire them up to our plugin we must write a required method supports:

const FILTER = '.filter';
const PREVENT = '.prevent';
const STOP = '.stop';

class FilteredEventPlugin {
  supports(event: string): boolean {
    return (
      event.includes(FILTER) || event.includes(PREVENT) || event.includes(STOP)
    );
  }
}

This way EventManager would know that it should delegate events with these modifiers in their names to our plugin. Next we need to be able to subscribe and unsubscribe from events. We will not add global events support since it is rarely needed for our use cases and writing those could be rather complex. So we will just strap our modifiers from event name and pass it back to EventManager so it could delegate it to the correct plugin for handling:

class FilteredEventPlugin {
  supports(event: string): boolean {
    // ...
  }

  addGlobalEventListener(
    element: string,
    eventName: string,
    handler: Function,
  ): Function {
    const event = eventName
      .replace(FILTER, '')
      .replace(PREVENT, '')
      .replace(STOP, '');

    return this.manager.addGlobalEventListener(element, event, handler);
  }
}

In case of regular element we would need to write our logic. To do so we will wrap handler into an arrow function and pass event with our modifiers removed back to EventManager, but this time we will do it outside ngZone so change detection would ignore this event:

class FilteredEventPlugin {
  supports(event: string): boolean {
    // ...
  }

  addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: Function,
  ): Function {
    const event = eventName
      .replace(FILTER, '')
      .replace(PREVENT, '')
      .replace(STOP, '');

    // Wrapper around our handler
    const filtered = (event: Event) => {
      // ...
    };

    const wrapper = () =>
      this.manager.addEventListener(element, event, filtered);

    return this.manager.getZone().runOutsideAngular(wrapper);
  }

  /*
  addGlobalEventListener(...): Function {
    ...
  }
  */
}

At this stage all we have is the name of the event, event itself and an element that we listen it on. The handler function that gets there is not our original listener but a chain of closures composed by Angular for its own needs.

One way to filter events could be adding an attribute to the element that would determine if we should react to this event or not. However sometimes to figure this out we need to take a look at the event itself: see what is the original target, tell whether default action was canceled or not etc. Therefore attribute is not sufficient and we need to find a way to define a filtering function that would receive event and our component instance as input and return true or false. Then we could write our handler this way:

const filtered = (event: Event) => {
  const filter = getOurHandler(some_arguments);

  if (
    !eventName.includes(FILTER) ||
    !filter ||
    filter(event)
  ) {
    if (eventName.includes(PREVENT)) {
      event.preventDefault();
    }

    if (eventName.includes(STOP)) {
      event.stopPropagation();
    }

    this.manager.getZone().run(() => handler(event));
  }
};

Solution

To handle this filter function business we would create a singleton service that would keep a Map of element to pairs of event names and filter functions. We would also need some other tools to set those mappings. Of course there could be multiple handlers of the same event on a single element but that would be the case of both @HostListener and a regular listener set on the same component in a template one level above. We would handle this case later.

Main service would be pretty simple. All we need is a Map and some methods to modify it:

export type Filter = (event: Event) => boolean;
export type Filters = {[key: string]: Filter};

class FilteredEventMainService {
  private elements: Map<Element, Filters> = new Map();

  register(element: Element, filters: Filters) {
    this.elements.set(element, filters);
  }

  unregister(element: Element) {
    this.elements.delete(element);
  }

  getFilter(element: Element, event: string): Filter | null {
    const map = this.elements.get(element);

    return map ? map[event] || null : null;
  }
}

This way we could inject this service into our plugin and get the correct filter by passing an element and the event name. To handle @HostListener`s we would add another small service that would live with the component and clear all filters on destruction:

export class EventFiltersService {
  constructor(
    @Inject(ElementRef) private readonly elementRef: ElementRef,
    @Inject(FilteredEventMainService)
    private readonly mainService: FilteredEventMainService,
  ) {}

  ngOnDestroy() {
    this.mainService.unregister(this.elementRef.nativeElement);
  }

  register(filters: Filters) {
    this.mainService.register(this.elementRef.nativeElement, filters);
  }
}

And to handle event subscription on elements in template we would need a similar directive:

class EventFiltersDirective {
  @Input()
  set eventFilters(filters: Filters) {
    this.mainService.register(this.elementRef.nativeElement, filters);
  }

  constructor(
    @Inject(ElementRef) private readonly elementRef: ElementRef,
    @Inject(FilteredEventMainService)
    private readonly mainService: FilteredEventMainService,
  ) {}

  ngOnDestroy() {
    this.mainService.unregister(this.elementRef.nativeElement);
  }
}

In case our service exists inside a component we will not allow this directive on it to avoid conflicts. After all, you can always wrap a component with another element and put directive there. We can tell if this element has our service if we optionally inject it with @Self() decorator:

class EventFiltersDirective {
  // ...

  constructor(
    @Optional()
    @Self()
    @Inject(FiltersService)
    private readonly filtersService: FiltersService | null,
  ) {}

  // ...
}

If this service is present we would print a message that the directive is not applicable to it:

class EventFiltersDirective {
  @Input()
  set eventFilters(filters: Filters) {
    if (this.eventFiltersService === null) {
      console.warn(ALREADY_APPLIED_MESSAGE);

      return;
    }

    this.mainService.register(this.elementRef.nativeElement, filters);
  }

  // ...
}

Putting this to use

All of the described code is available on StackBlitz:

As an example you can find a pseudo select component with a dropdown menu inside a modal popup there. In case of the menu, if you take a look at virtually any implementation it behaves the following way: if you hover an option with your mouse it would get focused, next you can navigate menu with the keyboard but if you move your mouse again — focus goes back to the hovered element. This is easy to implement, however we are not interested in mousemove events when hovered item is already focused. To get rid of tens of change detection cycles we can add a simple filter checking if target element is already focused.

Also in that select there is filtering of @HostListener subscription. When you press Esc inside popup it should close it. That should happen only if this action is not handled inside some nested component. In case of select, Esc should close the dropdown and move focus from menu back to select. But if it’s already closed — then select should not care about this action and it would bubble up to close popup. This case could be handled by using @HostListener('keydown.esc.filtered.stop') with a simple filter function () => this.opened.

When select loses focus we also need to close the dropdown. Because it’s a composite component with multiple focusable elements so we need focusout event to monitor focus status (because it bubbles). But this event is dispatched on any focus changes, even if focus never leaves component boundaries. focusout event has relatedTarget property which we could check to see where the focus is headed. We could write a filter to turn this focusout event into effectively bubblingblur event like that:

class SelectComponent {
  // ...

  focusOutFilter = ({relatedTarget}: FocusEvent) =>
    !this.elementRef.nativeElement.contains(relatedTarget);

  // ...

  @HostListener('focusout.filtered')
  onBlur() {
    this.opened = false;
  }

  // ...
}

Conclusion

Unfortunately, internal Angular handling of composite key press events would still run inside NgZone because it manually re-enters it in a corresponding internal plugin. We could try not to delegate event back to EventManager and implement it ourselves but the deeper we go into internal Angular realm — the riskier this venture becomes. Besides filtering those composite events should not really be necessary since they shouldn’t happen too rapidly. We could just use filters as early return operator if we’d like or skip it for these events entirely.

The approach described here allows us to control change detection cycle in sensitive events and still use handy built-in way of working with events in Angular. We can also now stop propagation or prevent default action in a declarative way right at the subscription, which is a common action of event handling. This method is a bit bulky but it gives a pretty good overview of how events are handled in Angular. It could also be improved by defining filters using TypeScript decorators which is something I plan to explore and share my findings in the next article, along with some other handy decorators I’ve been experimenting with.