10

In my template I have a field and two buttons:

<div class="btn-plus" (click)="add(1)"> - </div>
<div class="txt"> {{ myValue }} </div>
<div class="btn-minus" (click)="add(-1)"> + </div>

In my component .ts file I have:

add(num) {
    this.myValue +=num;
    this.update(); // async function which will send PUT request
}

The this.update() function puts myValue in the proper field in a big JSON object and sends it to a server.

Problem: When a user clicks 10x in a short period of time on button plus/minus, then a request will be send 10 times. But I want to send a request only once - 0.5 sec after last click. How to do it?

user2846469
  • 2,072
  • 3
  • 18
  • 32
Kamil Kiełczewski
  • 85,173
  • 29
  • 368
  • 345

7 Answers7

9

Use the takeUntil operator :

export class AppComponent  {
  name = 'Angular';

  calls = new Subject();

  service = {
    getData: () => of({ id: 1 }).pipe(delay(500)),
  };

  click() {
    this.calls.next(true);
    this.service.getData().pipe(
      takeUntil(this.calls),
    ).subscribe(res => console.log(res));
  }
}

Stackblitz (open your console to check the logs)

  • In Stackblitz every time when I click on "Simulate HTTP call" button i get immediately error "ERROR TypeError: _co.simulate is not a function" – Kamil Kiełczewski Nov 15 '18 at 13:00
  • Damn it, I renamed it --' Try again ! –  Nov 15 '18 at 13:01
  • Now is ok. I give +1 (~15 min ago), however this solution in not so reusable as Cory Rylan directive so I will not "check" it as best answer. But thank you for your answer – Kamil Kiełczewski Nov 15 '18 at 13:05
  • I'm not here to code a directive but to give you another lead, feel free to create a directive from that ! But I understand your position and I'm totally fine with it, even though the directive is way more complicated to use than that. –  Nov 15 '18 at 13:07
  • Can you please explain your solution a bit? It works but I can't make sense of it although I have checked the docs and played around with it. – Jason Lee Aug 25 '21 at 03:07
  • I also don't fully understand why this works, if you want an easier to understand method that also uses rxjs check my answer: https://stackoverflow.com/a/76018715/3331025 – cesar-moya Apr 14 '23 at 20:31
7

This is answer partially I found in internet, but I open to better solutions (or improve to below solution(directive)):

In internet I found appDebounceClick directive which helps me in following way:

I remove update from add in .ts file:

add(num) {
    this.myValue +=num;
}

And change template in following way:

<div 
    appDebounceClick 
    (debounceClick)="update()" 
    (click)="add(1)" 
    class="btn-plus"
    > - 
</div>
<div class="txt"> {{ myValue }} </div>
<!-- similar for btn-minus -->

BONUS

Directive appDebounceClick written by Cory Rylan (I put code here in case if link will stop working in future):

import { Directive, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
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);
  }
}
Kamil Kiełczewski
  • 85,173
  • 29
  • 368
  • 345
3

I ended up using a simplified version of the DebounceClickDirective posted above. Since debounceTime operator doesn't support leading/trailing options, I decided to use lodash. This eliminates the delay from click to action, which in my case was opening a dialog and was pretty annoying.

Then I just use it like this <button (debounceClick)="openDialog()">

import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
import { debounce } from 'lodash';

@Directive({
  selector: 'button',
})
export class DebounceClickDirective {
  @Output() debounceClick = new EventEmitter();

  @HostListener('click', ['$event'])
  debouncedClick = debounce(
    (event: Event) => {
      this.debounceClick.emit(event);
    },
    500,
    { leading: true, trailing: false },
  );
}
Andrei Cojea
  • 635
  • 6
  • 16
2

A helper function --

export const debounced = (cb, time) => {
  const db = new Subject();
  const sub = db.pipe(debounceTime(time)).subscribe(cb);
  const func = v => db.next(v);

  func.unsubscribe = () => sub.unsubscribe();

  return func;
};

Then an example use could be:

import { Component, OnInit } from '@angular/core';
import { debounced } from 'src/helpers';

@Component({
  selector: 'app-example',
  // Click calls `debouncedClick` instead of `myClick` directly
  template: '<button (click)="debouncedClick($event)">Click This</button>'
})
export class Example implements OnDestroy {
  debouncedClick; // Subject.next function

  constructor() {
    // Done in constructor or ngOnInit for `this` to resolve
    this.debouncedClick = debounced($event => this.myClick($event), 800);
  }

  // Called after debounced resolves (800ms from last call)
  myClick($event) {
    console.log($event);
  }

  ngOnDestroy() {
    // Stay clean!
    this.debouncedFunc.unsubscribe();
  }
}

Could also reverse the usage, calling the 'myClick' on click and have the debounced callback perform the desired action. Dealer's choice.

Personally I this for (keyup) events as well.

Unsure if the unsubscribe is really necessary - was quicker to implement than to research the memory leak :)

Charly
  • 881
  • 10
  • 19
2

You can implement this with a setTimeout if not want to use rxjs observable instance. This would be an ideal implementation with memory leak cleanup on ngOnDestroy:

@Component({
  selector: "app-my",
  templateUrl: "./my.component.html",
  styleUrls: ["./my.component.sass"],
})
export class MyComponent implements OnDestroy {
  timeoutRef: ReturnType<typeof setTimeout>;

  clickCallback() {
    clearTimeout(this.timeoutRef);
    this.timeoutRef  = setTimeout(()=> {
      console.log('finally clicked!')
    }, 500);
  }

  ngOnDestroy(): void {
    clearTimeout(this.timeoutRef);
  }
} 

Edit: Updated timeoutRef TS def to safer one as suggested by @lord-midi on the comments.

xtealer
  • 123
  • 1
  • 8
  • 1
    I think it's not a good idea to use "NodeJS.Timeout" inside of an Angular application. – Lord Midi Jun 29 '22 at 08:20
  • Hi, just did some research and found the following resources that suggest otherwise. The issue is not related to Angular2 apps but a Typescript discussion that can apply to other frameworks. Still, will update accordingly to a "safer" types definition. 1. [Typescript: What is the correct type for a Timeout?](https://stackoverflow.com/questions/60245787/typescript-what-is-the-correct-type-for-a-timeout) 2. [TypeScript - use correct version of setTimeout node vs window](https://stackoverflow.com/questions/45802988/typescript-use-correct-version-of-settimeout-node-vs-window) – xtealer Jun 30 '22 at 09:23
1

On Click event fir only first time so you don't need to wait for last click. OR how to ignore subsequent events ?

Solution by Ondrej Polesny on freecodecamp Website Also thanks to Cory Rylan for nice explanation about Debouncer

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

@Directive({
  selector: '[appDebounceClick]'
})

export class DebounceClickDirective {

  @Input() debounceTime: number = 800;
  @Output() debounceClick: EventEmitter<any> = new EventEmitter();

  private onClickDebounce = this.debounce_leading(
    (e) => this.debounceClick.emit(e), this.debounceTime
  );

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

  private debounce_leading(func: any, timeout: number) {
    let timer;
    return (...args) => {
      if (!timer) {
        func.apply(this, args);
      }
      clearTimeout(timer);
      timer = setTimeout(() => {
        timer = undefined;
      }, timeout);
    };
  };

}
MD Ashik
  • 9,117
  • 10
  • 52
  • 59
0

A simpler to understand option is to use a custom subject that emits on click and simply use rxjs native debounceTime. Live Example (open console logs): Stackblitz

// declare these variables
clicker = new Subject();
clicker$ = this.clicker.asObservable();

//Add to ngOnInit(), change the number according to how sensitive you want to debounce
this.clicker$.pipe(debounceTime(200)).subscribe(() => {
     console.log('Requesting Data ...');
     this.service.getData().subscribe((d) => console.log(d));
});

// your button's (click) function:
this.clicker.next(true);
cesar-moya
  • 581
  • 5
  • 8