Angular - Creating a Debounce Directive(s)


A very common and useful directive that we can create is a simple debounce directive. If you’re not familiar with debouncing, it essentially will discard events or values until a specified time has ellapsed between the emission of those events/values. This is commonly used to prevent spam button clicks or spamming an API call from a autocomplete/search input.

We’re going to create two debounce directives, one to debounce key up events that will be used on input elements and another to debounce button clicks.

 

A stackblitz example of our directives

 

Creating a debounce directive

Since we are creating multiple debounce directives to handle different events (keyup, click, etc), we will create a base class to hold all common/shared logic between the different events we are debouncing.

 

abstract-debounce.directive.ts

import { Directive, OnDestroy, Input, Output, EventEmitter } from "@angular/core";
import { Subject } from "rxjs";
import { takeUntil, debounceTime, distinctUntilChanged, tap } from "rxjs/operators";

@Directive()
export abstract class AbstractDebounceDirective implements OnDestroy {
  @Input()
  public debounceTime: number;

  @Output()
  public onEvent: EventEmitter<any>;

  protected emitEvent$: Subject<any>;
  protected subscription$: Subject<void>;

  constructor() {
    this.debounceTime = 500;
    this.onEvent = new EventEmitter<any>();
    this.emitEvent$ = new Subject<any>();
    this.subscription$ = new Subject<void>();
  }

  ngOnInit(): void {
    this.emitEvent$
      .pipe(
        takeUntil(this.subscription$),
        debounceTime(this.debounceTime),
        distinctUntilChanged(),
        tap(value => this.emitChange(value))
      )
      .subscribe();
  }

  public emitChange(value: any): void {
    this.onEvent.emit(value);
  }

  ngOnDestroy(): void {
    this.subscription$.next();
    this.subscription$.complete();
  }
}

 

Our debounce directive will have two bindings, one @Input binding to set the debounce time, and an @Output to bind the callback when a debounced event is emitted.

Binding Description
[debounceTime] The time in milliseconds to debounce event (default is 500ms)
(onEvent) The output event callback after debouncing

 

The key to our debounce directive is the Rxjs Subject (emitEvent$) which we will use to pipe our events/values with several Rxjs operators. So do our pipe operators do?


takeUntil

  • This simply handles our subscription to our emitEvent$ Subject. Our subscription will complete once the subscription$ Subject emits value. This operator could ommited and our subscription could be handled with a Subscription instead.

debounceTime

  • This operator will debounce events/values based on the specified time, which in our case is our @Input value (defaulted to 500ms). So a value will not be emitted until the specified time has elapsed between events/values.

distinctUntilChanged

  • This operator will discard duplicate consecutive events. Basically will not emit the value if the value hasn’t changed since the last emission.

tap

  • This is where we will handle the value that should be emitted after being piped through debounceTime and distinctUntilChanged. This could also be handled in the subscription callback instead of using the tap operator.

 

Debouncing a key up event

Now that we have a base logic in place for our debouncing, we can create directives to debounce different event types. Our first directive will debounce key up events. This can be useful for autocomplete/search inputs where you’re make an API call to get some resultset based on user input. The debounce will prevent the API call on every keyup but instead will call the API after the specified time between events is reached.

 

debounce-keyup.directive.ts

import { Directive, HostListener } from "@angular/core";
import { AbstractDebounceDirective } from "./abstract-debounce.directive";

@Directive({
  selector: "input[fooDebounceKeyUp]"
})
export class DebounceKeyupDirective extends AbstractDebounceDirective {
  constructor() {
    super();
  }

  @HostListener("keyup", ["$event"])
  public onKeyUp(event: any): void {
    event.preventDefault();
    this.emitEvent$.next(event);
  }
}

Here we are simply using the @HostListener decorator to listen to key up events then pushing them through our emitEvent$ Subject in our AbstactDebounceDirective base class. This will pipe our event through the pipe operators and finally emit the event if it successfully passes through.

 

Debouncing a click event

Our DebounceClickDirective will be identical to our DebounceKeyUpDirective except that we will be listening to click events with the @HostListener decorator instead of keyup events.

 

debounce-click.directive.ts

import { Directive, HostListener } from "@angular/core";
import { AbstractDebounceDirective } from "./abstract-debounce.directive";

@Directive({
  selector: "[fooDebounceClick]"
})
export class DebounceClickDirective extends AbstractDebounceDirective {
  constructor() {
    super();
  }

  @HostListener("click", ["$event"])
  public onKeyUp(event: any): void {
    event.preventDefault();
    this.emitEvent$.next(event);
  }
}

 

Stackblitz Example

Below I’ve embedded a Stackblitz of our directives in action. The button will debounce click with a 2 second debounce time. The input will debounce key up event with a 1 second debounce time.

 


comments powered by Disqus