ngconf
Published in

ngconf

How to Handle Multiple Click Events in Angular & RxJs

handling multiple clicks — comic

web application is like an animal from the zoo that you set free and release into the wild production. If it’s not robust enough it will be torn down by fearless users. If it only knows you and thinks it can trust other humans, too, it cannot end well. One of the cruel things that can happen to it is a double click. Watch for yourself:

So when multiple clicks are particularly dangerous?

handling multiple clicks — comic

Clicks usually trigger some methods. Those methods often involve some CRUD actions, e.g. via RESTful APIs. Not all of them are idempotent, e.g. cannot be safely repeated.

An HTTP method is idempotent if an identical request can be made once or several times in a row with the same effect while leaving the server in the same state. In other words, an idempotent method should not have any side-effects (except for keeping statistics).

GET, HEAD, PUT and DELETE are considered idempotent (if you use PUT only to overwrite existing entries, not to create one). POST is not, it will create new items whenever the user hits the button, resulting in duplicates (if not properly handled). So, watch out for POST, but with GET, HEAD, PUT and DELETE we are safe, right? Not really.

An HTTP method is safe if it doesn’t alter the state of the server. In other words, a method is safe if it leads to a read-only operation.

So, what can happen if we don’t handle double-clicks properly? 2+ DELETE requests will be triggered. Upon the first one the server deletes the item with id XYZ. Milliseconds later the second request arrives and asks for the same. If backend server does not have a strategy how to deal with deleting a non-existing item, it will crash. Otherwise it will “just” return an error, which might reach the user: “oh-oh we were not able to delete the item XYZ”.

Long story short: double-click causing GET or PUT will result in unnecessary requests (a.k.a. performance!), POST could create duplicates, DELETE might have server crash or UX problems as consequence. So, let’s handle it!

Handling multiple clicks

Well, first of all, you could consider the dbClick event. That simple! Yet it is poorly supported on mobile devices and what about… triple clicks?

The next straightforward approach is to disable the button when the call has started. Make sure you have different loading-booleans for all the buttons in your component, though. To me, it is not the optimal solution yet. We can do better in terms of DRYness and UX.

Another simple solution is to use a setTimeout() and check on each click if the timeout is already set. If so, you know it's a second/third/forth click within a given time window (multiple click). If the timeout expires, you know it was just a single click. The example below has been taken from this Stack Overflow question and changed a bit, so that we now just ignore multiple clicks. If you need to handle single and double / multiple clicks differently, check out the original discussion.

In your template:

<button (click)=getItem($event)></button>

In your component.ts:

// count the clicks
private clickTimeout = null;
public getItem(itemId: string): void {
if (this.clickTimeout) {
this.setClickTimeout(() => {});
} else {
// if timeout doesn't exist, we know it's first click
// treat as single click until further notice
this.setClickTimeout((itemId) =>
this.handleSingleClick(itemId));
}
}
// sets the click timeout and takes a callback
// for what operations you want to complete when
// the click timeout completes
public setClickTimeout(callback) {
// clear any existing timeout
clearTimeout(this.clickTimeout);
this.clickTimeout = setTimeout(() => {
this.clickTimeout = null;
callback();
}, 200);
}
public handleSingleClick(itemId: string) {
//The actual action that should be performed on click
this.itemStorage.get(itemId);
}

What we are doing here, is basically debouncing. This is a form of rate limiting in which a function isn’t called until it hasn’t been called again for a certain amount of time. That is to say, if the debouncing time is 200ms, as long as you keep calling the function and those calls are within a 200ms window of each other, the function won’t get called. Sounds perfect! Yet it has been reported that setTimeout() can interfere with the ongoing button animation of the first call.

Let’s try debouncing with RxJs then. Source: Preventing multiple calls on button in Angular.

--- your.component.ts ---...
private buttonClicked = new Subject<string>();
...
public ngOnInit(){ const buttonClickedDebounced =
this.buttonClicked.pipe(debounceTime(200));
buttonClickedDebounced.subscribe((itemId: string) =>
//The actual action that should be performed on click
{
this.itemStorage.get(itemId);
}
);
}
public getItem(itemId: string) {
this.buttonClicked.next(itemId);
}

Do you have multiple buttons in your app that should ignore multiple clicks? It screams for a directive. Check out this tutorial. It uses a slightly different approach: the responsibility for debouncing clicks moves to the button itself.

The minor drawback is that debouncing waits till the end of the debounce time to emit a new value. If the user only clicks once on the button, the call will be triggered 200ms later.

So here is another idea for RxJs gurus. We have our Subject buttonClicked (as above). The new event will be emitted whenever the user clicks the button. GroupBy groups all emitted values as per the itemId and applies exhaustMap to each unique group. ExhaustMap on its turn creates a new inner Observable. Only after it completes, the next value (i.d. the next unique id group) is considered. So, groupBy ensures that the same requests are not triggered multiple times in a row. Here is a code snippet.

--- your.component.ts ---
...
private buttonClicked = new Subject<string>();
...
public onInit(){
this.buttonClicked.pipe(
groupBy((itemId) => itemId),
mergeMap((groupedItemIds) =>
groupedItemIds.pipe(
exhaustMap((itemId) => {
//The actual action that
//should be performed on click
return this.itemStorage.get(itemId);
}
),
take(1),
catchError((error) => throwError(error)),
),
),
).subscribe((itemId) => {
// Handle display logic
});
}
public getItem(itemId: string) {
this.buttonClicked.next(itemId);
}
handling multiple clicks — comic

The strongest limitation is, however, that you need way to identify which requests are the same to be able to group by these values. The easiest way to do this is if your object has an id field you can reference, but any unique property will work. So, this approach is not suitable for something like getAllItems(), but is perfect for deleteItem(itemId: string). No reason for panic, though: exhaustMap in combination with debounceTime is the way to go for getAllItems().

Uff, we’ve covered a lot. Here is an overview of possible approaches for multi-click handling:

  • disable the button for the duration of the first call
  • setTimeout()
  • debounce clicks, also as a button directive
  • use groupBy and exhaustMap to ignore subsequent identical requests

Ready to build the most reliable web applications possible? Join us for the Reliable Web Summit this August 26th-27th, 2021 to learn how! A summit conference on building reliable web applications, including the following three pillars:

  • Scalable infrastructure and architecture
  • Maintainable Code
  • Automated Testing

https://reliablewebsummit.com/

--

--

The World’s Best Angular Conference

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Maria Korneeva

Learning tech hacks by sharing them with you— that is what drives me. #learningbysharing