Have you ever faced your web application struggling to manage rapid user interaction, leading to slow performance or unnecessary resources/server calls? That common problem, where functions fire too quickly between interactions, like search bar typing or resizing, is exactly what “debouncing “ fixes.
This optimization technique was borrowed from electrical engineering; it’s used to manage event frequency variation, ensuring a much smoother and efficient experience.
But you may be thinking: How does that relate to web development? And maybe you’re right, the implementation is different, but the concept is the same. This concept is translated to the field of web development by delaying the execution of a resource or function call when the trigger event of that execution is being called too frequently.
Hands-on code!
Let’s see that concept in action. Imagine the following scenario: You’re creating a search feature in your e-commerce store, and you want it to be dynamic, so the results are filtered as the user types. But the searching logic is done in the back end, so you need to make a call to the /search?term="test"
endpoint, passing the searched term as a query parameter.
So you create an input and call the endpoint, passing the term when the input changes
const Products = () => {
const [searchResults, setSearchResults] = useState([]);
useEffect(() => {
fetchAndSetProducts(setSearchResults);
}, []);
async function search(term) {
const data = await filterProducts(term);
setSearchResults(data);
}
return (
<div className="products-container">
<div className="search-container">
<input
type="text"
name="search"
id="search"
placeholder="search..."
onChange={async (e) => await search(e.target.value)}
/>
</div>
{searchResults.map((product, index) => (
<Product key={product.id} product={product} />
))}
</div>
);
};
You test it, and WOW, what a smooth-looking search, huh? WRONG! It might look smooth, but you just created a huge problem for your server; every letter that your user types creates a new request to it.
But how to avoid doing this? There are a lot of different solutions to this, but in that case, we’ll be using debounce. With debounce, we’ll delay the call of the endpoint just a little 🤏, so if the user is typing, the endpoint will not be called, but if the user does a small break, then the call happens. But how do we accomplish that?
A debounce function receives the target function to call and a timeout value (delay), then it creates an address to the timeout instance and a callback function, that callback function wraps our target function in a setTimeout
with the specified delay value.
function debounce(fn, timeout = 400) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), timeout);
};
}
But as you can see, if the callback function is called, it clears and recreates the timeout instance, so if we call the callback function before the delay time ends, it will restart the countdown. So, to get our target function called, we need to wait until the delay time ends. Let’s see that working in the search feature. First, we need to pass our search function as a parameter to the debounce and define a timeout value, in our case, 200ms.
async function search(term) {
const data = await filterProducts(term);
setSearchResults(data);
}
const debouncedSearch = debounce(search, 200);
Then, in our component template, we can just call the “debounced” function
<div className="products-container">
<div className="search-container">
<input
type="text"
name="search"
id="search"
autoComplete="off"
placeholder="search..."
onChange={async (e) => debouncedSearch(e.target.value)}
/>
</div>
{searchResults.map((product, index) => (
<Product key={product.id} product={product} />
))}
</div>;
Testing it, we can see that it still works very smoothly, but without overcharging our server with a bunch of unnecessary calls, because now, it’s waiting for me to finish typing before calling it. And if the user types very slowly, maybe to see what the search returns for a specific letter or something, it still returns results. The best of both worlds.
Conclusion
Debouncing, while a simple technique, proves to be incredibly useful in optimizing web applications by effectively managing rapid user interactions. As demonstrated with the search feature example, debouncing prevents an overload of server requests, which can lead to performance issues and unnecessary resource consumption. By introducing a slight delay in function execution, debounce ensures that computationally intensive operations, like API calls, only occur when a user has paused their input, striking a balance between responsiveness and efficiency. This method, borrowed from electrical engineering, ultimately leads to a smoother and more efficient user experience without overcharging your application.
Thanks for reading until this point, now here’s a gift for you!
To help you with a future implementation, as React allows us to abstract logic to create our own hooks, here is a snippet of an optimized version of debounce in React, that is reusable and uses memoization for better performance.
import { useRef, useCallback, useEffect } from 'react';
function useDebounce(callback, delay) {
const timeoutRef = useRef(null);
const callbackRef = useRef(callback);
// Update the callback ref if the callback function changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedCallback = useCallback(
(...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay]
);
// Cleanup the timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedCallback;
}
export default useDebounce;
Top comments (0)