Maria Korneeva | ng-conf | Oct 2020
A 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?
". There is a chat bubble stating "Click me!". The 2nd panel shows the same button, now smiling with its eyes closed. There is a finger pressing the button down and the chat bubble reads "Oh yesss...". The 3rd panel shows the button, its face now wide eyed and wide mouthed. The finger has moved, as if it's about to press down again, and the chat bubble reads "One time is enough!!! What are you...". The 4th panel shows the button being pressed again. Its face is now two x's for eyes and a tongue sticking out as though dead. The chat bubble reads "...doing""/>
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);
}
". The button is smiling wide with its eyes open similar to ":D". There is a chat bubble reading "Click me!". The second picture is of the same button who is smiling with its eyes closed. There is a finger on the button in a similar manner to it being pushed, the chat bubble now reads "More...". The remaining two pictures are the same as the second."/>
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
ng-conf: The Musical is coming
ng-conf: The Musical is a two-day conference from the ng-conf folks coming on April 22nd & 23rd, 2021. Check it out at ng-conf.org
[Disclaimer: did I miss something / is something not quite correct? Please let me and other readers know AND provide missing/relevant/correct information in your comments — help other readers (and the author) to get it straight! a.k.a. #learningbysharing]
Top comments (0)