Make your React Apps more performant using Debouncing & Throttling! ๐ฅ๐
A simple guide to optimizing your React Apps performance using Debouncing & Throttling.
Well, hello there! ๐ I see you have come here to learn more about how to make your React apps performant and optimize them using Debouncing and Throttling, which is great because that means you really do care about your app's performance. Kudos for that! ๐
Do note that this blog assumes that you have a basic understanding of how React works and that you are familiar with React Hooks.
Before we jump in, let's understand why would you want to optimize your React app's performance?
Suppose you have a very simple React app with an input bar to search cities like the below,
As you can see, this app is super laggy and the UX of this app is ๐ฉ. We are just making a very simple search that filters cities from a list of cities based on user input.
PS:- You can give it a try if you want (Please do it at your own risk, you don't wanna hang your computer!) - codesandbox.io/s/debouncing-example-demo-0d..
Now, you might ask why's this React app so laggy?
If you would have noticed carefully from the above demo of the app, we are filtering the cities from the list of cities that we have on every keystroke made by the user (notice the keystrokes on the Virtual keyboard in the demo).
See now, that's not a performant app at all and needs to be optimized to provide a better user experience.
Let's have a look at two ways to optimize such apps and make them better!
What is Debouncing & Throttling?
There are many scenarios that make your app less performant like making API calls on each user keystroke on an input search bar, performing compute-heavy operations on button clicks, window resizing, or frequent scrolls on a scrollbar.
Basically, any scenario in which you are making expensive (in terms of computing or execution time) function calls on events or user actions that can hamper the performance of your apps.
Now, let's understand Debouncing & Throttling.
Debouncing: In debouncing, we try to reduce the number of expensive function calls by calling them only if the time difference between two consecutive event triggers (user actions) is greater than or equal to a specified delay. This delay can be adjusted depending on the use case or the kind of user experience you are trying to design for your app.
Throttling: In throttling, we try to rate-limit the number of expensive function calls by calling them every time only after a certain time limit has passed from the last function call. Again, this time limit can be adjusted depending on your use case.
Debouncing & Throttling can be very useful to handle rate-limit errors caused by rate-limiting on certain APIs that your apps might be consuming, as we are trying to reduce the number of such expensive function calls using these optimizations.
Now that you have some idea about Debouncing & Throttling, let's dive deeper into each concept using a simple example that illustrates some of their common use cases.
Optimizing apps using Debouncing
Let's go back to the very first example that we saw, where we had a simple search bar that filters the cities from a list of cities based on the user input.
We can use debouncing in this case to reduce the number of function calls to filter the cities from the list.
But first, let's look at the initial code from the demo.
Initial Code -
import "./styles.css";
import React, { useState } from "react";
import cities from "cities-list";
import { v4 as uuidv4 } from "uuid";
// An array of city names
const citiesArray = Object.keys(cities);
export default function App() {
const [cityInput, setCityInput] = useState("");
const [filteredCities, setFilteredCities] = useState([]);
// Function that filters cities from the list based on user input
const cityFilter = (query) => {
console.log(query);
if (!query) return setFilteredCities([]);
setFilteredCities(
citiesArray.filter((city) =>
city.toLowerCase().includes(query.toLowerCase())
)
);
};
return (
<div className="App">
<h1 className="app-header">Find cities</h1>
<div className="city-input">
<input
type="text"
value={cityInput}
onChange={(e) => {
setCityInput(e.target.value);
cityFilter(e.target.value);
}}
/>
</div>
<div>
{filteredCities.map((city) => {
return <div key={uuidv4()}>{city}</div>;
})}
</div>
</div>
);
}
The above code snippet represents a simple React component with an input search bar, and a container that displays the filtered cities.
// Function that filters cities from the list based on user input
const cityFilter = (query) => {
console.log(query);
if (!query) return setFilteredCities([]);
setFilteredCities(
citiesArray.filter((city) =>
city.toLowerCase().includes(query.toLowerCase())
)
);
};
The function cityFilter
takes a user search query as an input parameter and filters the cities from a list of cities (fetched from an npm package called cities-list
). Currently, this function runs on every single keystroke made by the user on the search bar.
Now, let's write a debounced version of the above cityFilter
function to make it more optimal. We will be using setTimeout
in JavaScript to achieve this.
// `timer` to help while clearing setTimeout
// inside `debouncedCityFilter` function
let timer;
// Debounced version of the `cityFilter` func to filter cities
// based on user search query
const debouncedCityFilter = (query) => {
clearTimeout(timer);
if (!query) return setFilteredCities([]);
timer = setTimeout(() => {
console.log(query);
setFilteredCities(
citiesArray.filter((city) =>
city.toLowerCase().includes(query.toLowerCase())
)
);
}, 500);
};
According to the concept of debouncing, we make function calls only if the time difference between two consecutive event triggers (user actions) is greater than or equal to a specified delay.
In the above code snippet, we set the state to get the filtered cities using setFilteredCities()
which is called inside a setTimeout
with a delay of 500ms
(this delay can be adjusted according to the use case). So whenever a user keystroke is recorded on the input search bar, the debouncedCityFilter
function is called which triggers setTimeout
and sets the state using setFilteredCities()
after 500ms
.
However, if another keystroke made by the user is recorded just within this time delay of 500ms
, the previous setTimeout
needs to be cleared to avoid filtering the cities and set the state. For this, we use clearTimeout
that takes the id
returned by the setTimeout
function.
Now, this id
needs to be preserved so that it is available whenever we need to use clearTimeout
to clear the timer. We use a quite popular concept called Closures in JavaScript to be able to access this id
inside the debouncedCityFilter
function. Hence, if you'd have noticed we have defined a timer
variable outside the debouncedCityFilter
function for use inside this function.
By simply debouncing the cityFilter
function, we are able to reduce the number of function calls and hence able to improve the performance significantly of our React app.
Let's have a look at what our React component code looks like after making these changes.
Final Code -
import "./styles.css";
import React, { useState } from "react";
import cities from "cities-list";
import { v4 as uuidv4 } from "uuid";
// An array of city names
const citiesArray = Object.keys(cities);
// `timer` to help while clearing setTimeout
// inside `debouncedCityFilter` function
let timer;
export default function App() {
const [cityInput, setCityInput] = useState("");
const [filteredCities, setFilteredCities] = useState([]);
// Function that filters cities from the list based on user input
const cityFilter = (query) => {
console.log(query);
if (!query) return setFilteredCities([]);
setFilteredCities(
citiesArray.filter((city) =>
city.toLowerCase().includes(query.toLowerCase())
)
);
};
// Debounced version of the `cityFilter` func to filter
// cities based on user search query
const debouncedCityFilter = (query) => {
clearTimeout(timer);
if (!query) return setFilteredCities([]);
timer = setTimeout(() => {
console.log(query);
setFilteredCities(
citiesArray.filter((city) =>
city.toLowerCase().includes(query.toLowerCase())
)
);
}, 500);
};
return (
<div className="App">
<h1 className="app-header">Find cities</h1>
<div className="city-input">
<input
type="text"
value={cityInput}
onChange={(e) => {
setCityInput(e.target.value);
debouncedCityFilter(e.target.value);
}}
/>
</div>
<div>
{filteredCities.map((city) => {
return <div key={uuidv4()}>{city}</div>;
})}
</div>
</div>
);
}
Now, have a look at how debouncing has improved the performance of this component significantly! ๐
If you want to handle more edge cases for debouncing such functions, then you can check out Lodash which has a debounce
method that covers most of the edge cases involved to make such functions more optimal.
Now, let's look at a simple example that uses Throttling to make it more performant.
Optimizing apps using Throttling
Let's suppose, you have a simple React component consisting of a button
that on clicking calls an API to fetch some data related to all the currencies of different countries.
Initial Code -
import "./styles.css";
import React, { useState } from "react";
import axios from "axios";
import { v4 as uuid } from "uuid";
export default function App() {
const [currencyData, setCurrencyData] = useState({});
const [clickCounter, setClickCounter] = useState(0);
const getCurrencyData = async () => {
console.log("Fetching data ....");
const { data } = await axios.get(
"https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies.json"
);
// Fetching only 15 currencies for now
const countryCurrencies = {};
const currencyObjKeys = Object.keys(data).slice(0, 15);
currencyObjKeys.forEach((key) => {
countryCurrencies[key] = data[key];
});
setCurrencyData({ ...countryCurrencies });
};
return (
<div className="App">
<h1>Currencies of different Countries</h1>
<button
className="currency-btn"
onClick={() => {
setClickCounter((clickCount) => clickCount + 1);
getCurrencyData();
}}
>
Click to get all currencies
</button>
<span>Btn clicked - {clickCounter} times</span>
<div className="currencies">
{Object.keys(currencyData).map((currency) => {
return (
<div key={uuid()}>
{currency}: {currencyData[currency]}
</div>
);
})}
</div>
</div>
);
}
The code snippet above is our simple component with two states - currencyData
& clickCounter
. On button click, we update the clickCounter
state to reflect the total number of button clicks made so far and also call the getCurrencyData()
function to make an API call to fetch the currency data.
Let's look at what this component looks like!
As you might have noticed above, each button click triggers an API call. Now, imagine that your app was used by hundreds or thousands of users, the number of API calls would be humongous! Your Back-End servers could face a huge pool of requests coming from each user due to so many clicks. Also, if you are consuming any external paid API or service, then the endpoints might start throwing errors because of rate-limiting on the API endpoints.
Even if say you were not making any API calls on such button clicks but rather performing some compute-heavy operation, it would hamper your app's performance severely!
Now, that's a bit of a problem ๐
Let's try and solve this problem using Throttling! โจ
We will throttle the getCurrencyData
function that makes an API call on each button click.
Currently, the code for getCurrencyData
looks like this,
const getCurrencyData = async () => {
console.log("Fetching data ....");
const { data } = await axios.get(
"https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies.json"
);
// Fetching only 15 currencies for now
const countryCurrencies = {};
const currencyObjKeys = Object.keys(data).slice(0, 15);
currencyObjKeys.forEach((key) => {
countryCurrencies[key] = data[key];
});
setCurrencyData({ ...countryCurrencies });
};
Now, we will write a function throttledGetCurrencyData
that will throttle and use the getCurrencyData
function to reduce the number of calls made to it.
// A flag to control the function calls to the `getCurrencyData` function
let shouldFuncBeCalled = true;
const throttledGetCurrencyData = async () => {
if (shouldFuncBeCalled) {
await getCurrencyData();
shouldFuncBeCalled = false;
setTimeout(() => {
shouldFuncBeCalled = true;
}, 500);
}
};
The throttledGetCurrencyData
function calls the getCurrencyData
function only if the shouldFuncBeCalled
flag is set to true
. Once this function is called, we delay the next function call to the getCurrencyData
function by using setTimeout
with some specific delay (This delay limit can be adjusted as per your use case).
This way we only allow function calls to happen after a certain amount of time has passed from the last function call. This way we can avoid making the UI slow or crossing the rate limits defined for any API that your app might be consuming.
Let's look at how the app is working now.
As you can see from the console, the number of API calls has been reduced quite significantly even after clicking the button so many times!
Check out the CodeSandbox below to see what the code for our component looks like after using Throttling.
If you want to handle more edge cases for throttling such functions, then you can check out Lodash which has a throttle
method that covers most of the edge cases involved to make such functions more optimal.
Debouncing vs Throttling, when to use what?
Now that we understand how Debouncing and Throttling work, let's understand some of the differences and when to use Debouncing or Throttling.
Throttling enforces that a function must be called every time after a certain amount of time (or delay) has passed from the last function call.
Whereas, Debouncing enforces that a function must only be called if a certain amount of time (or delay) has passed without it being called. If this time has not been passed the debounce timer keeps resetting and the function call is avoided.
When to use what?
Search bar: Use debouncing to avoid searching every time a user hits a keystroke. Throttling is not convenient here to use in this scenario, as you don't want to make your user wait too long for fetching the search results (in the worst case if the previous function call was made just when the user stops typing).
Shooting game: Use throttling on mouse click as shooting a pistol takes a few secs to register and it helps in avoiding the user to shoot until the previous shot has been registered. Debouncing will not shoot a bullet until a certain amount of time has passed when the pistol was not fired.
You can also check out this amazing Stackoverflow post to understand the differences between Debouncing & Throttling and when to use what.
Conclusion
Debouncing & Throttling are just a few ways you can make your React apps more performant & each technique has its own set of pros and cons depending on the use cases. In this blog, we first talked about Why should we care about our React app's performance, then we understood how we can use Debouncing & Throttling to optimize our app's performance, and finally saw a major difference between the two techniques and when we to use which technique.
That's it from me folks, thank you so much for reading this blog! ๐ I hope this blog was helpful and gave you an insight on how you can make your React apps more performant. Now, go ahead and make your apps even more amazing! ๐
Feel free to reach out to me:
GitHub
You can also reach out to me over mail: devansuyadav@gmail.com