While building the profile page in my React Native-based social networking app, I encountered an interesting problem:

How do I manage API endpoint abuse if the user spams the like button?

The like button being spammed.The like button is vulnerable to spam by the end-user.Each tap on the button results in an API call informing the server about the action, and a subsequent DB call by the server updating the database state to reflect the action.What may seem an innocuous call can wreak havoc with the app’s caching architecture if fired repeatedly — The cache, if not managed efficiently, would need to be reset multiple times within seconds due to database state updates.

Formalizing the Problem and UI Requirements

Let’s note down all the requirements to manage this problem efficiently. The end-user needs a smooth UX, while the server wants minimal load.

UX Requirements

The like action must be reflected in the app immediately. Even if the server is slow to respond, the heart icon should be filled/emptied immediately on liking/disliking the post respectively.
This differs from typical API call flows, which allow you to display a loader and deactivate the action button while waiting for a server response. Using the same approach here would kill the UX, making the whole experience sluggish.The UI state must eventually be consistent with the server’s state, that is, an unsuccessful API call (user’s action) must be reflected on the like icon and the like count shown.Like and dislike actions are alternate. We can use this fact to our advantage if one of our API calls fails due to a 5xx response.

Server Requirements

The calls to the server should be minimal.You cannot dislike a post that was not liked by you. Trying this would result in a 400 BAD REQUEST response.You cannot like a post that was already liked by you. Trying this would, again, result in a 400 BAD REQUEST response.The server can send a 5xx response if it faces difficulties in serving our requests.

Terminology

Before going further, I would like to clarify the meaning of the following terms, if they were not straightforward:

Client State or UI State is the state that is visible to the user. This includes the “like” icon (filled on like/unfilled on unlike), and the like count.Server State is the state of the database, i.e. the true state of our data.

Updates to the server state (API calls) take orders of magnitude more time than updates to the client state (React State Updates).
Our goal is to eventually reconcile these two states while ensuring that client state updates are not slowed down by server state updates.

Potential Solutions

Traditional solutions to this problem involve debouncing and function throttling on the UI or rate-limiting the API on the server.
My thought process in this queue-based approach was to take advantage of the fact that an API call can take several hundred milliseconds, and use the call as a natural debouncer. Rate-limiting the API is still viable.

Final Solution

After deliberating on the potential solutions, I crafted a React hook maintaining a queue of the user’s actions (like/unlike). It exposes a method, pushToQueue, to enable actions to be pushed into it.

The client state is immediately updated upon the user’s interaction.
The network call updating the server state is pushed to a queue.As soon as a non-empty queue is detected, the app enters the processing state. The final action (like/unlike) is decided by parsing the queue, and the API call informing the server of the decided action is fired.Now, the API call may take several hundred milliseconds.
The user may fire additional interactions on the same button during the call, which are pushed to the queue for processing later. (Again, the client state is updated immediately upon these interactions. It will be updated again after the queue is processed, reflecting the server’s state)On completion of the API call, we check if there are additional items in the queue to be processed. If present, the entire process is repeated. Otherwise, the processing is marked complete, and the client state is updated accordingly.
The delay introduced by the API call thus acts as a natural debouncer.

Without further ado, let’s build the thing!

You can check out the final code at https://github.com/rb3198/playgrounds
You can also follow the process below step by step by checking out this PR.View the changes commit by commit for a better understanding of the development process.

Implementation

Initializing the App

First, let’s create a simple App displaying a heart icon and the like count:

It stores the following in its state:

Boolean storing whether the post is liked or notThe like count (number).const [state, setState] = useState({
liked: false,
likeCount: 0,
});

And displays the info using a heart icon and text:

return (
<div className=”App”>
<div id=”heart_container”>
/*Fill the icon if liked, else no fill (transparent)*/
<HeartIcon
height={96}
width={96}
stroke=”#aaa”
fill={liked ? “rgb(207, 102, 121)” : “transparent”}
/>
</div>
/* Display the like count */
<p>{likeCount}</p>
</div>
);

Next, we define a simple onClick Handler, that likes/dislikes the post:

Liking sets the boolean value to true, and increments the like count.Disliking sets the boolean value to false, and decrements the like count.const like = useCallback(() => {
setState((prevState) => ({
liked: true,
likeCount: prevState.likeCount + 1,
}));
}, []);

const dislike = useCallback(() => {
setState((prevState) => ({
liked: false,
likeCount: prevState.likeCount – 1,
}));
}, []);

const onClick = useCallback(() => {
if (liked) {
dislike();
} else {
like();
}
}, [liked]);

After a bit of styling, the App looks like this:

Disliked and Liked States

Finally, let’s set up the hook.

The Hook — Props

Our hook will take in the following props:

postId: number: ID of the post that is to be liked. The API call will be fired bound with this ID.mutate(postId: number, liked: boolean, postId: number) => Promise<boolean> :
An async function that is responsible for calling the API. This should return the response’s status in boolean (whether the call was successful or not).
As with any network call, it may also throw an error.setLikedState: (liked: boolean) => React.SetStateAction:
A function that updates the UI State.onError?: (error: Error) => unknown: A callback to fire if the API call throws an error.

The Hook — Storing the Queue

We will store the queue of actions in a React ref. Its values will be the possible actions — like or dislike.

const queueRef = useRef<(“like” | “dislike”)[]>([]);Why a ref, and not a state you ask? Well, that is because state updates are reflected only on subsequent renders, while ref updates are immediate.If you are a beginner, please note that updates to ref values do not trigger a re-render like state updates do.

Lastly, we’ll store a state telling us whether the queue is currently being processed or not.

const [processing, setProcessing] = useState(false);

This will be set to true whenever the final action is being decided and the API call is being made. Whenever the queue is empty (processed), it will be set to false.

The Hook — Processing the Queue

The following is the working mechanism of our queue:

We set the processing state to true.We create a copy of our queue to not mutate the original queue.We iterate through the copy and decide the final action ( like, dislike, or none):setProcessing(true);
const initialQueueLength = queueRef.current.length;
// Copy of the ref to not mutate the ref while processing.
const queueCopy = […queueRef.current];
let finalAction: “like” | “dislike” | “none” = “none”;
while (queueCopy.length) {
const type = queueCopy.shift();
if (type === “like”) {
finalAction = finalAction === “dislike” ? “none” : “like”;
} else {
finalAction = finalAction === “like” ? “none” : “dislike”;
}
}

We now have a decision to make — Should we call the API?
If the final action is decided to be none, no API call is required. We:

Slice our ref by the number of items we processed ( initialQueueLength)Check if the queue is non-empty. The queue would be non-empty if the user pressed the like button while our loop was running.If non-empty, we repeat the process, calling the processQueue function again. Else, we terminate our function.if (finalAction === “none”) {
queueRef.current =
queueRef.current.slice(initialQueueLength);
if (queueRef.current.length > 0) {
await processQueue();
}
setProcessing(false);
return;
}Note: In this case, updating our UI state is not required, since our component would’ve already updated the state instantaneously on the click of the like button.
Also note that this case can only be reached if two opposing actions were performed quickly when our queue was being processed, canceling each other out.

If the final action is decided to be like/unlike, we call our API. We maintain a variable execSuccess which tracks if our call was successful.

let execSuccess = false;
try {
execSuccess = await mutate(postId, finalAction === “like”);
} catch(error) {
execSuccess = false;
onError(error);
}

Finally, we need to update our UI state depending on the response from the server if there are no more items in the queue.
If our API call was unsuccessful, and the queue is non-empty, we can discard the first element of our queue since the actions are alternate in nature.
Ex: If the call was for like, we can be sure that the next action pushed to the queue would’ve been dislike. Since our like was unsuccessful, we can discard the dislike pushed to our queue before processing it, since an unsuccessful like action equals a dislike action for our UI state.

finally {
// Discard the items already processed.
queueRef.current =
queueRef.current.slice(initialQueueLength);
if (queueRef.current.length > 0) {
// Process the queue again if non-empty.
// User actions were pushed to the queue while the call was being made
if (!execSuccess) {
/* Pop the first item if the call was unsuccessful due to
the alternate nature of our actions */
queueRef.current.pop();
}
await processQueue();
} else if (execSuccess) {
setLikedState(finalAction === “like”);
} else {
setLikedState(finalAction !== “like”);
}
setProcessing(false);
}

The Hook — Push Function

Lastly, we will define our push function. It accepts an action ( like / dislike) as an argument and simply pushes it to our queue.
If the queue is not being processed (The processing state variable is false), it calls our processQueue function.
Our hook returns this function.

const pushToQueue = useCallback(
(action: “like” | “dislike”) => {
const newQueue = […queueRef.current, action];
queueRef.current = newQueue;
if (!processing) processQueue();
},
[processing]);

return [pushToQueue];

Consuming our Hook and Testing the App

The API

We will use a mocked API to test our code. It will have a 20% failure rate and will return true if successful and false otherwise. It will be artificially delayed by 500ms.

const API_DELAY = 500;
const responses = [true, true, false, true, true];
let idx = 0;
const postLikeMapping = new Map<number, boolean>();
export const mockedApiCall = async (postId: number, liked: boolean) => {
// Fake delay
await new Promise((resolve) => setTimeout(resolve, API_DELAY));
if (!postLikeMapping.get(postId) && !liked) {
throw new Error(“Cannot dislike a post that has not been liked.”);
}
if (postLikeMapping.get(postId) && liked) {
throw new Error(“Cannot like a post that has already been liked.”);
}
// Simulated 80% success rate.
if (responses[idx++ % responses.length]) {
postLikeMapping.set(postId, liked);
return true;
}
return false;
};

Our app will call this function whenever the user presses the like or dislike button.

Hook consumption in our App

Finally, we integrate the hook we created with our app.

We change our liked state to a React reducer.
– The reducer won’t change the state if the same action is called consecutively (once immediately by the UI and once on a successful API call by the hook).
– The reducer will update the state if it is modified by the action. This will happen when the UI updates the state immediately but the API call fails, resetting the state.We consume the hook and call the pushToLikeQueue method exposed by the hook on user interaction.// Reducer Code
const likedReducer: Reducer<{ liked: boolean; likeCount: number }, boolean>
= (
state,
liked
) => {
const { likeCount } = state;
if (liked === state.liked) {
return state;
}
return {
liked,
likeCount: liked ? likeCount + 1 : likeCount – 1,
};
};// Consuming the hook.
const [pushToLikeQueue] = useLikes({
setLikedState: dispatchLiked,
postId: 1,
async mutate(postId, liked) {
return await mockedApiCall(postId, liked);
},
});

const like = useCallback(() => {
dispatchLiked(true); // Immediately update the UI State
pushToLikeQueue(“like”); // Queue the API call.
}, [pushToLikeQueue]);

const dislike = useCallback(() => {
dispatchLiked(false); // Immediately update the UI State
pushToLikeQueue(“dislike”); // Queue the API call.
}, [pushToLikeQueue]);

Testing out our Logic

Our mocked API code will fail on every fifth attempt. You can see a live demo below: On the left is the UI, and on the right is the console revealing what’s going on behind the scenes.

Conclusion

Et voila! Now we have a reliable liking / unliking mechanism that minimizes API calls and keeps the UI responsive and up to date!

You can view the PR creating this hook here.
You can demo this app by following these steps:

Clone the Playgrounds Repo.Navigate to react/use-likes-hook-demo.Run npm install in the folder.Run npm start in the folder.Play with the app.

Efficiently Managing Rapid Fire User Actions in React with a Queue-Based Hook was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

​ Level Up Coding – Medium

about Infinite Loop Digital

We support businesses by identifying requirements and helping clients integrate AI seamlessly into their operations.

Gartner
Gartner Digital Workplace Summit Generative Al

GenAI sessions:

  • 4 Use Cases for Generative AI and ChatGPT in the Digital Workplace
  • How the Power of Generative AI Will Transform Knowledge Management
  • The Perils and Promises of Microsoft 365 Copilot
  • How to Be the Generative AI Champion Your CIO and Organization Need
  • How to Shift Organizational Culture Today to Embrace Generative AI Tomorrow
  • Mitigate the Risks of Generative AI by Enhancing Your Information Governance
  • Cultivate Essential Skills for Collaborating With Artificial Intelligence
  • Ask the Expert: Microsoft 365 Copilot
  • Generative AI Across Digital Workplace Markets
10 – 11 June 2024

London, U.K.