Debounce @HostListener event

I really like @yurzui's solution and I updated a lot of code to use it. However, I think it contains a bug. In the original code, there is only one timeout per class but in practice one is needed per instance.

In Angular terms, this means that if the component in which @debounce() is used is instantiated multiple times in a container, every instantiation will cancelTimeout the previous instantiation and only the last will fire.

I propose this slight variant to eliminate this trouble:

export function debounce(delay: number = 300): MethodDecorator {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {

    const original = descriptor.value;
    const key = `__timeout__${propertyKey}`;

    descriptor.value = function (...args) {
      clearTimeout(this[key]);
      this[key] = setTimeout(() => original.apply(this, args), delay);
    };

    return descriptor;
  };
}

Of course, it is possible to be more sophisticated about disambiguating the synthetic __timeout__ property.


An RXJS way of doing this can be achieved using fromEvent together with the throttleTime operator.

Instead of decorating your event handler with @HostListener, you create an observable from the event using fromEvent (e.g., in the ngOnInit method) and then throttling the emission of events using throttleTime.

...
import {fromEvent, Subscription} from 'rxjs';
import {tap, throttleTime} from 'rxjs/operators';


export class MyComponent implements OnInit, OnDestroy { 

  private eventSub: Subscription;

  ngOnInit() {
    this.eventSub = fromEvent(window, 'scroll').pipe(
      throttleTime(300), // emits once, then ignores subsequent emissions for 300ms, repeat...
      tap(event => this.scroll(event))
    ).subscribe();
  }

  scroll(event) {
    ...
  }

  ngOnDestroy() {
    this.eventSub.unsubscribe(); // don't forget to unsubscribe
  }
}

One advantage of using RXJS is that you can pass in custom schedulers to the throttleTime operator to achieve different behaviours. For example, you can throttle event emission by the animation frame rate (e.g., to throttle the emission of touch events).

import {animationFrameScheduler, ...} from 'rxjs';
...

this.eventSub = fromEvent(window, 'touchmove').pipe(
  throttleTime(0, animationFrameScheduler),
  tap(event => ...)
).subscribe();

I would leverage debounce method decorator like:

export function debounce(delay: number = 300): MethodDecorator {
  return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
    const timeoutKey = Symbol();

    const original = descriptor.value;

    descriptor.value = function (...args) {
      clearTimeout(this[timeoutKey]);
      this[timeoutKey] = setTimeout(() => original.apply(this, args), delay);
    };

    return descriptor;
  };
}

and use it as follows:

@HostListener('window:scroll', ['$event'])  
@debounce() 
scroll(event) {
  ...
}

Ng-run Example

Tags:

Angular

Rxjs