Creating a Custom Debounce Click Directive in Angular
In this post, we will cover the Angular Directive API to create our custom debounce click directive. This directive will handle denouncing multiple click events over a specified amount of time. This directive is used to help prevent duplicate actions.
The Directive API is a particular way to add behavior to existing DOM elements or components. For our use case, we want to debounce or delay click events from occurring when an element is a click. To do this, we will cover concepts from the Directive API, HostListener API, and RxJS.
First, we need to create our Directive class and register it to our app.module.ts
.
import { Directive, OnInit } from '@angular/core';
@Directive({
selector: '[appDebounceClick]'
})
export class DebounceClickDirective implements OnInit {
constructor() {}
ngOnInit() {}
}
An Angular Directive is essentially a component without a template. The behavior defined our Directive class will be applied to the host element.
<button appDebounceClick>Debounced Click</button>
The Host Element in the markup above is our HTML button. The first thing we want to do is listen to when the Host Element is clicked. So let's add the following code to our directive.
import { Directive, HostListener, OnInit } from '@angular/core';
@Directive({
selector: '[appDebounceClick]'
})
export class DebounceClickDirective implements OnInit {
constructor() {}
ngOnInit() {}
@HostListener('click', ['$event'])
clickEvent(event) {
event.preventDefault();
event.stopPropagation();
console.log('Click from Host Element!');
}
}
In our example above we are using an Angular decorator called @HostListener
. This decorator allows you to listen to events on the Host Element easily. In our example, the first parameter is the click
event. The second parameter $event
allows us to tell Angular to pass in the click event to our Directive method clickEvent(event)
.
With the click event we can call event.preventDefault();
and event.stopPropagation();
. These two lines prevent the click event from bubbling up to the parent component. We want this behavior, so we can control when the click event fires.
Debounce Events
Now that we can intercept the Host Element click event we need to have a way to debounce those events and then re-emit them back to the parent. To this, we have two parts to implement, the Event Emitter and a RxJS Subject to debounce the Events.
import {
Directive,
EventEmitter,
HostListener,
OnInit,
Output
} from '@angular/core';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
@Directive({
selector: '[appDebounceClick]'
})
export class DebounceClickDirective implements OnInit {
@Output() debounceClick = new EventEmitter();
private clicks = new Subject();
constructor() {}
ngOnInit() {
this.clicks
.pipe(debounceTime(500))
.subscribe(e => this.debounceClick.emit(e));
}
@HostListener('click', ['$event'])
clickEvent(event) {
event.preventDefault();
event.stopPropagation();
this.clicks.next(event);
}
}
In the code above we are using an Angular Decorator @Output
. The output Decorator with the EventEmitter
class allows us to create custom event on DOM elements and components. To emit events, we call the emit
event on the Event Emitter instance.
We don't want to emit the click event immediately; we wish to debounce or delay the event. To get this behavior, we will use a RxJS class called a Subject
. A Subject allows us to listen to events as well as emit them. In our code, we create a subject to handle our click events. On our method, we call .next()
to have the Subject emit the next value. We can also use a special RxJS function operator called debounceTime
. This allows us to debounce the event based on a given number of milliseconds on the Subject events.
Once we have this setup, we can now listen to our custom debounce click event in our template like below.
<button appDebounceClick (debounceClick)="log()">Debounced Click</button>
Now when we click our button, it is debounced by 500 milliseconds. After 500 milliseconds of no clicking, our Directive will emit the click event. Now we have the necessary functionality we need to do some cleanup work and add a little more functionality.
Unsubscribe
With RxJS Observables and Subject we must unsubscribe from the events once we are done listening to them. If we don't, we can accidentally create memory leaks.
import {
Directive,
EventEmitter,
HostListener,
Input,
OnInit,
Output
} from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
@Directive({
selector: '[appDebounceClick]'
})
export class DebounceClickDirective implements OnInit {
@Output() debounceClick = new EventEmitter();
private clicks = new Subject();
private subscription: Subscription;
constructor() {}
ngOnInit() {
this.subscription = this.clicks
.pipe(debounceTime(500))
.subscribe(e => this.debounceClick.emit(e));
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
@HostListener('click', ['$event'])
clickEvent(event) {
event.preventDefault();
event.stopPropagation();
this.clicks.next(event);
}
}
To unsubscribe we catch the subscription object that is returned when subscribing to a class property. When Angular destroys or removes the DOM element, it will call the OnDestroy
life cycle hook where we can unsubscribe from our Subject events.
Custom Inputs
Our directive is fully functional and handles events correctly. Next, we are going to add a little more logic to allow us to customize the debounce time whenever needed. To do this, we will use the @Input
decorator.
import {
Directive,
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
@Directive({
selector: '[appDebounceClick]'
})
export class DebounceClickDirective implements OnInit, OnDestroy {
@Input() debounceTime = 500;
@Output() debounceClick = new EventEmitter();
private clicks = new Subject();
private subscription: Subscription;
constructor() {}
ngOnInit() {
this.subscription = this.clicks
.pipe(debounceTime(this.debounceTime))
.subscribe(e => this.debounceClick.emit(e));
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
@HostListener('click', ['$event'])
clickEvent(event) {
event.preventDefault();
event.stopPropagation();
this.clicks.next(event);
}
}
The @Input
decorator allows us to pass data into our Components and Directives. In the code above we can take in a input to specify how long we would like the debounce time to be. By default, we will set it to 500 milliseconds. With the @Input
we can now set this value in our templates like below.
<button appDebounceClick (debounceClick)="log()" [debounceTime]="700">
Debounced Click
</button>
Feel free to check out the full working demo in the link below.