Context
There are many events in JS that are triggered in the blink of an eye.
When you scroll the page, resize the window or even move your mouse, the browser captures dozens and dozens of events per second.
In many cases though, it is not necessary to capture all the intermediate steps because you're usually only interested in capturing the final state (when the user has finished scrolling or resizing the window).
Debouncing is a strategy that allows us to improve performance by waiting for a given amount of time to elapse before triggering an event. So when the user stops triggering an event, our code runs.
In most cases, this is not necessary. But, if network requests or DOM changes are involved (e.g. rendering a component), this technique can greatly improve the fluidity of your application.
Usage
const handleScroll = debounce((event) => {
// Do something every time a user scrolls
console.log("Scrolling!");
}, 250);
window.addEventListener('scroll', handleScroll);
Explanation
This function is not the easiest to wrap your head around, especially if you are not used to functional programming! Sure, you can use this function without understanding it, but if you're curious and you should let's find out how it works:
const debounce = (callback, wait) => {
let timeoutId = null;
// Takes any number of arguments using spread syntax
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
callback.apply(null, args);
}, wait);
};
}
Our debounce function takes two arguments: a function and a duration in milliseconds.
We want debounce() to itself return a function. Functions returning functions always hurts my brain, but it this is how I like to think of it:
Think of it as a chef who creates a recipe to cook a dish. When the chef finishes the recipe, they can either serve the dish or pass it on to another chef. In case of the debounce(), it's like the first chef has passed on the recipe to a second chef, who can then use it to cook a new dish.
The second chef can modify the recipe, add new ingredients, or even use it as a base to create a completely new dish. Similarly, the returned function can modify the original function, add new functionality, or use it as a base to create a new function.
Notice that the first line of this function initializes a variable, **timeoutId. This line is executed only once. We plan to call our wrapper function several times, but we only call debounce() at the beginning.
Each time the wrapper function is called, two things happen:
We cancel any existing setTimeout
So, the very first time the user scrolls, this first step has no effect since there is no active timeout. Fortunately, clearTimeout is a very forgiving function; even if there is no setTimeout in progress, it does not complain. It's a no-op - it does nothing.We schedule a setTimeout for a duration determined by the wait argument. When the timeout expires, we call our callback function with apply, passing down every arguments.
Once programmed, setTimeout returns a number, a reference to the timeout in question. We store this in our timeoutId property to be able to clean it later on if needed. Since this variable is instantiated on the provided function which is defined outside the scope of our wrapper function, it persists.
Let's say the user has finished scrolling. A few milliseconds (corresponding to the given wait parameter) pass, and our wrapper is called again.
This time, timeoutId points to a scheduled timeOut, so the first step cancels it before scheduling a new one.
If the user scrolls again, this cycle will repeat itself infinitely. A lot of timeOuts are scheduled and immediately cancelled. Scheduling and cleaning up timeouts is a very fast and low memory cost operation, so we don't have to worry about the cost.
Conclusion
The final snippet in a compact expression :
const debounce = (callback, wait) => {
let timeoutId = null;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
callback.apply(null, args);
}, wait);
};
}
Top comments (0)