48

I have a component with click.

<my-box (click)="openModal()"></my-box>

When I click this element, openModal function will run. And I'd like to give 1000ms throttle time in order to prevent opening multiple modals.

My first approach was using Subject (from rxJs)

//html
<my-box (click)="someSubject$.next()"></my-box>
//ts
public someSubject$:Subject<any> = new Subject();
...etc subscribe

But I feel it's a bit verbose.

Next Approach was using a directive. I modified a bit of code that I found by googling.

//ts
import {Directive, HostListener} from '@angular/core';

@Directive({
    selector: '[noDoubleClick]'
})
export class PreventDoubleClickDirective {

    constructor() {
    }

    @HostListener('click', ['$event'])
    clickEvent(event) {
        event.stopPropagation();    // not working as I expected.
        event.preventDefault();     // not working as I expected.

        event.srcElement.setAttribute('disabled', true);    // it won't be working unless the element is input.
        event.srcElement.setAttribute('style', 'pointer-events: none;');   // test if 'pointer-events: none' is working but seems not. 

        setTimeout(function () {
            event.srcElement.removeAttribute('disabled');
        }, 500);
    }
}

//html
<my-box noDoubleClick (click)="openModal()"></my-box>

However, whatever I try, always openModal was executed. I couldn't find how to stop executing openModal in the directive.

I can just make like

//ts
//In the openModal method.
openModal() {
    public isClickable = true

    setTimeout(() => {
        this.newsClickable = true;
    }, 1000);
    ...
}

But for the reusable code, I think using directive is ideal.

How can I make it?

Téwa
  • 1,184
  • 2
  • 13
  • 19

10 Answers10

73

Since some people asked for the throttleTime directive, I'll add it below. I chose to go this route because the debounceTime waits for the last click before firing the actual click event. throttleTime will not allow the clicker to click the button again until that time is reached and instead fires the click event immediately.

Directive

import { Directive, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { throttleTime } from 'rxjs/operators';

@Directive({
  selector: '[appPreventDoubleClick]'
})
export class PreventDoubleClickDirective implements OnInit, OnDestroy {
  @Input()
  throttleTime = 500;

  @Output()
  throttledClick = new EventEmitter();

  private clicks = new Subject();
  private subscription: Subscription;

  constructor() { }

  ngOnInit() {
    this.subscription = this.clicks.pipe(
      throttleTime(this.throttleTime)
    ).subscribe(e => this.emitThrottledClick(e));
  }

  emitThrottledClick(e) {
    this.throttledClick.emit(e);
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

  @HostListener('click', ['$event'])
  clickEvent(event) {
    event.preventDefault();
    event.stopPropagation();
    this.clicks.next(event);
  }
}

Example Usage

throttleTime is optional since there is a default of 500 in the directive

<button appPreventDoubleClick (throttledClick)="log()" [throttleTime]="700">Throttled Click</button>

If you have a bot that's clicking on your element every 1ms, then you'll notice that the event only ever fires once until the throttleTime is up.

Luca Ritossa
  • 1,118
  • 11
  • 22
Jon La Marr
  • 1,358
  • 11
  • 14
  • 3
    This is brilliant. You could also extend the directive with throttleTime `config` `{leading: false, trailing: true}` to only allow double clicking. By default it's `{ leading: true, trailing: false }`. – Drenai Sep 16 '19 at 17:50
  • What is "log()" param? I tried change by event but dosen't works. – Miguel Navas Nov 05 '21 at 08:07
  • @MiguelNavas `throttleClick` is an event emitter. When that event is emitted, your component that theoretically would have a `log` function (taken directly from the accepted answer above) would be called. https://dzone.com/articles/understanding-output-and-eventemitter-in-angular https://angular.io/api/core/EventEmitter https://docs.angular.lat/guide/inputs-outputs – Jon La Marr Nov 05 '21 at 16:15
  • This should be the way – Pavan Jadda May 26 '22 at 17:05
  • How I can use conditional click event? – Shubham Sep 15 '22 at 07:59
64

You can use RxJs' debounce or debounceTime operator to prevent double clicks. Here is also a post on how to create a custom debounce click directive.

In case the post is taken down in the future, here is the final code:

Directive:

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);
  }
}

Example Usage:

<button appDebounceClick (debounceClick)="log()" [debounceTime]="700">Debounced Click</button>
Sam Herrmann
  • 6,293
  • 4
  • 31
  • 50
  • It works like a charm! I didn't think about Output in the directive. – Téwa Jul 17 '18 at 22:50
  • Is it possible to use the original (click) attribute to reduce the number of new attributes created? – adamsfamily Aug 14 '20 at 16:15
  • @sam-herrmann Is the event.stopPropagation() really needed? This will prevent any parent component from handling the click event. For example a global inactivity timer that listens to all click events – ZolaKt Nov 11 '20 at 13:52
  • I can't think of a reason why you couldn't leave out event.stopPropagation(). But if I needed to propagate the event, I'm not sure if I'd dispatch the event within the subscribe after this.debounceClick.emit(e). I guess the question is, do you consider a click that's being debounced and therefore doesn't have an effect as an "activity"? If you consider all clicks an activity even if they don't make it past the debounceTime operator then you can probably just remove event.stopPropagation(). – Sam Herrmann Nov 11 '20 at 18:09
  • I know this is a bit old, but if we use a directive like this one say 100 buttons on an app, does this have a negative effect on performance? I can see all 100 buttons do their own subscribe and unsubscribe. – brett Oct 27 '21 at 15:48
  • @brett probably no perceptible performance hit, but if it became and issue `event delegation` might be needed – Drenai Apr 16 '22 at 06:57
14

In my case throttleTime instead of debounce was better solution(fire event immediately and block until some time passed)

repo
  • 748
  • 1
  • 8
  • 19
11

I propose a simpler approach for buttons:

    import {Directive, ElementRef, HostListener} from '@angular/core';

    const DISABLE_TIME = 300;
    
    @Directive({
        selector: 'button[n-submit]'
    })
    export class DisableButtonOnSubmitDirective {
        constructor(private elementRef: ElementRef) { }
        @HostListener('click', ['$event'])
        clickEvent() {
            this.elementRef.nativeElement.setAttribute('disabled', 'true');
            setTimeout(() => this.elementRef?.nativeElement?.removeAttribute('disabled'), DISABLE_TIME);
        }
    }

Example usage:

    <button n-submit (click)="doSomething()"></button>
Stefan Norberg
  • 1,137
  • 13
  • 28
  • 1
    Thanks for the solution! Also would be great to care about the fact that element might be dstroyed during DISABLE_TIME. So might consider using safe construction like `this.elementRef?.nativeElement?.removeAttribute(…` – Maxím G. Aug 17 '22 at 20:18
2

Below code work for me to prevent double click.

onClick(event) {
    const button = (event.srcElement.disabled === undefined) ? event.srcElement.parentElement : event.srcElement;
        button.setAttribute('disabled', true);
        setTimeout(function () {
        button.removeAttribute('disabled');
        }, 1000);
    //Your code}

And HTML:

<button class="btn btn-save" (click)="onClick($event)">
                        Prevent Double click
                    </button>
amol rajput
  • 171
  • 1
  • 2
  • 3
0

Or maybe want prevent many clicks on button? I'm using following solution:

import { Directive, HostListener } from '@angular/core';

@Directive({
    selector: '[disableAfterClick]'
})
export class DisableButtonAfterClickDirective {
    constructor() { }

    @HostListener('click', ['$event'])
    clickEvent(event) {
        event.preventDefault();
        event.stopPropagation();
        event.currentTarget.disabled = true;
    }
}

I don't know if it is the most effective and elegant, but it works.

ohdev
  • 79
  • 5
0

I'd use a custom directive.

Put it somewhere in your template:

<button appSingleClick (singleClick)="log()" [throttleMillis]="1000">click</button>

The SingleClickDirective directive

import {Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core';
import {fromEvent, Subscription} from 'rxjs';
import {throttleTime} from 'rxjs/operators';

@Directive({
  selector: '[appSingleClick]'
})
export class SingleClickDirective implements OnInit, OnDestroy {
  private subscription: Subscription;

  @Input()
  throttleMillis = 1500;

  @Output()
  singleClick = new EventEmitter();

  constructor(private elementRef: ElementRef) {
  }

  ngOnInit(): void {
    this.subscription = fromEvent(this.elementRef.nativeElement, 'click')
      .pipe(throttleTime(this.throttleMillis))
      .subscribe((v) => {
        this.singleClick.emit(v);
      });
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
    this.singleClick.unsubscribe();
  }

}
Alexey
  • 7,127
  • 9
  • 57
  • 94
0

I decide to combine great ideas from the people who replied above, also used more natural way to detect component destroying (destroyed$ = new Subject() ... using takeUntil(this.destroyed$) and this.destroyed$.next() in ngOnDestroy).

Also, used throttleTime and a special use case when an element is a button - in that case it also become disabled (for the same throttle time) and then enabled again. It does a check if button was already disabled - then do nothing with it (in terms of disabling). So you wound't encounter an issue when previously disabled button got enabled after click.

Another small detail is that directive selector is the same as output event name, it means that you can use it just with one attribute, not two, like this: <div (appClickOnce)="myMethod()"></div>

So, here is the full code, any questions/suggestions are welcome:

import { Directive, ElementRef, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { of, Subject, timer } from 'rxjs';
import { throttleTime, takeUntil, tap, map, switchMap, mapTo } from 'rxjs/operators';

@Directive({
  selector: '[appClickOnce]'
})
export class ClickOnceDirective implements OnInit, OnDestroy {

  @Input() clickLockTime = 500; // ms
  @Output() appClickOnce = new EventEmitter<MouseEvent>();
  
  private clicks$ = new Subject();
  private destroyed$ = new Subject();

  constructor(private elementRef: ElementRef) {}

  ngOnInit() {
    this.clicks$.pipe(
      throttleTime(this.clickLockTime),
      tap((event: MouseEvent) => this.appClickOnce.emit(event)),
      map(() => {
        if ((this.elementRef?.nativeElement as HTMLElement)?.tagName === 'BUTTON') {
          const button = this.elementRef?.nativeElement as HTMLButtonElement;
          if (button.disabled) {
            return false;
          }
          button.disabled = true;
          return true;
        } else {
          return false;
        }
      }),
      switchMap((needToEnableButton: boolean) => !needToEnableButton ? of() :
        timer(0).pipe(
          tap(() => {
            if (needToEnableButton && (this.elementRef?.nativeElement as HTMLElement)?.tagName === 'BUTTON') {
              (this.elementRef?.nativeElement as HTMLButtonElement).disabled = false;
            }
          })
        )
      ),
      takeUntil(this.destroyed$),
    ).subscribe();
  }

  ngOnDestroy() {
    this.destroyed$.next();
    this.destroyed$.complete();
  }

  @HostListener('click', ['$event'])
  clickEvent(event: MouseEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.clicks$.next(event);
  }

}

And exmamples of use:

<div (appClickOnce)="myMethod()"></div>
<div (appClickOnce)="myMethod()" [clickLockTime]="700"></div>

And when you use it in button, the button get disabled for small amount of time (for clickLockTime milliseconds):

<button (appClickOnce)="myMethod()">Test me</button>
Maxím G.
  • 860
  • 10
  • 14
0

create a custom directive that disables the button after it has been clicked. This approach is more flexible, as it allows you to specify the amount of time that should elapse before the button is re-enabled.

import { Directive, ElementRef, Input } from '@angular/core';

@Directive({
  selector: '[appDisableButton]'
})
export class DisableButtonDirective {
  @Input() appDisableButton: number;
  private button: HTMLButtonElement;

  constructor(private el: ElementRef) {
    this.button = this.el.nativeElement;
  }

  ngOnChanges() {
    this.button.disabled = true;
    setTimeout(() => {
      this.button.disabled = false;
    }, this.appDisableButton);
  }
}

You can then use this directive by adding the appDisableButton attribute to your button element and binding it to the amount of time (in milliseconds) that the button should be disabled. For example:

<button appDisableButton="1000">Click me</button>
abhinavsinghvirsen
  • 1,853
  • 16
  • 25
0

Custom Directive without "debounce" operator.

Template:

<button preventDoubleClick (сlick)="log()">click</button>

Directive:

import {Directive, ElementRef, OnDestroy, OnInit} from '@angular/core';
import {fromEvent, Subscription} from 'rxjs';
import {tap} from 'rxjs/operators';

@Directive({
  selector: '[preventDoubleClick]'
})
export class PreventDoubleClickDirective implements OnInit, OnDestroy {
  private clickSubscription: Subscription = new Subscription();

  constructor(private elementRef: ElementRef) {
  }

  public ngOnInit(): void {
    this.clickSubscription.add(fromEvent(this.elementRef.nativeElement, 'click', {
      capture: true,
    }).pipe(
      tap((event: MouseEvent) => {
        if (event.detail > 1) {
          event.preventDefault();
          event.stopImmediatePropagation();
        }
      })
    ).subscribe());
  }

  public ngOnDestroy(): void {
    this.clickSubscription.unsubscribe();
  }
}