Debouncing in JavaScript for Everyone

ยท

12 min read

Debouncing in JavaScript for Everyone

In this article, the concept of Debouncing is explained in a simplified way. We'll go step by step and visualize every concept thoroughly.

Before learning anything, we must understand its use and the reason for existing.
So what's the reason we have the concept of Debouncing?

We often need to do some expensive computations in Javascript repeatedly but don't want to do them at very high frequencies. Debouncing is a strategy to limit the frequency of expensive computations.

Examples and scenarios -

  1. Let's say there's an input field. We want to search and display some content on a page by fetching the content from a backend API based on the input field's text as a search query. If a user writes 10 characters in the input field very fast, the API call will be made after every character is typed. Ten API calls will be made but most of the calls are redundant and not needed.
    Example - If a user types "New York", then API calls will be made to "N", "Ne", "New", "New ", "New Y", "New Yo", "New Yor", "New York" and so on. In this case, we only want to call the API when the user has paused typing for some time. Ideally, we should only call the API after "New" and "New York" as there's some delay after typing "New" and typing stopped at "New York".

  2. When we listen to some events like resize, scroll, or when the mouse pointer is moved. They fire too many events in every intermediate step. We don't want to call some expensive callback function in every intermediate step. We want to call the callback function in some discrete points where there is some pause.

Prerequisites

If you are already familiar with the concepts explained below, you can skip them or just have a look to recap the concepts.

Functions are first class objects

Functions in JavaScript are the first class objects, which means JavaScript functions are just a special type of object, which can do all the things that regular objects do like strings, arrays, etc. We can do the following with the functions in JavaScript - pass as a parameter to another function, return the function from another function, assign it to a variable, etc

function outer() {
    let outerText = "Stewie"
    function inner(text){
        console.log(text, outerText)
    }
    return inner
}

let funcRef = outer()
funcRef("Hello")

// Output will be "Hello Stewie"

In the above code, there is an outer() function and an inner() function. While calling the outer() function, it simply returns the inner() function's reference that we save in a variable funcRef(). This will be simply like calling inner() with its arguments.

Everything looks good till now, but one thing to notice - While calling funcRef() it looks normal to get access to outerText variable. If you observe closely, we're not calling inner() inside the outer() function's code block, we're returning inner() function's reference from there. When outer() is called and the moment it returned the inner() function's reference, the execution context of outer is removed. We're able to access outerText from funcRef (which is ultimately calling the inner() function) because of closures.
I just wanted to point this out or it gives an illusion of accessing data without embracing closures. I'll writeabout closures in some other article.

Callback function

A callback function in javascript is a function that is passed in another function as an argument to be called later.

function squareOfEven(evenNumber){
    console.log(evenNumber*evenNumber)
}

function outerFunction(callback, someNumber){  
    if (someNumber % 2 == 0){
        callback(someNumber)
    }
}

// Calling the function
outerFunction(squareOfEven, 20)
outerFunction(squareOfEven, 15)

// Output will be 400 for the 1st outerFunction call and nothing for 2nd call

In the above code, squareOfEven() is a callback function that is not called directly and passed to outerFunction() as an argument. outerFunction() calls squareOfEven() inside its code block when needed, so squareOfEven() is a callback function.

setTimeout and clearTimeout

setTimeout - setTimeout() function is a web API that is used to execute a piece of code or a function after a specific delay.
Syntax - setTimeout(() => function(), delay)
setTimeout() is used in some other ways also. You can read it in detail here

function printHello(){
    console.log("Hello World")
}

setTimeout(() => printHello(), 5000)

We're passing an arrow function returning printHello() and setting a delay of 5000ms (delay in setTimeout() is in milliseconds). setTimeout() will call printHello() after 5000ms, hence "Hello World" will be printed after 5 seconds.

clearTimeout - In the above code we have set a timer to call printHello() after 5 seconds. If the situation changes and we want to clear the timer such that printHello() is not called even after 5000ms has passed and it's time to execute the printHello() function; we use clearTimeout() for this.

Every setTimeout() has an id associated with it through which we can identify a particular timer. If there are 5 timers in our code then every timer has a different id.
We simply need to get the timer id and pass it in clearTimeout() to remove it.

function printHello(){
    console.log("Hello World")
}

// Getting timer ids
const timerId1 = setTimeout(() => printHello(), 5000)
const timerId2 = setTimeout(() => printHello(), 7000)

clearTimeout(timerId2)

In the above code, we created two timers of 5 seconds and 7 seconds each, then saved the ids of both timers in a variable. Later we used timerId2 to clear the second timer, so this code will only execute printHello() once after 5000ms.
If you pass an id to clearTimeout() which doesn't exist, it'll not throw any error or complain about it. It'll simply be ignored as if nothing happened. (Remember this point, it's very important)

setTimeout() and clearTimeout() is not as simple as this, but for our current use case, it's enough to know this much. I'll write an article on deep-dive of setTimeout later.

Rest and Spread operator

Rest parameter syntax helps in calling a function with any number of arguments, no matter how it is defined.

function multiplyAll(...args){
    let finalValue = 1
    for(let item of args){
        finalValue = finalValue*item
    }
    console.log(finalValue)
}

multiplyAll(2, 3, 5) // 30
multiplyAll(5, 8, 2, 10) // 800
multiplyAll(2, 1, 5, 3, 4, 6) // 720

In the above code multiplyAll() takes any number of arguments and stores them in an array named args. The name you put in place of arg after 3 dots in the function definition, that'll be the name of the array inside multiplyAll().

Spread syntax is used to pass an array as an argument after spreading it. The above function multiplyAll accepts any number of arguments, but it accepts them in comma separated way. How to pass an array of indefinite length?

const nums = [2, 5, 3, 4]

multiplyAll(nums[0], nums[1], nums[2], nums[3]) // 120

The above code is a way to call myltiplyAll with array data, but it's a very bad way to call the function by passing each value one by one. Imagine, we have arrays of lengths 100, 1000 and so on, how would we pass that? Here spread syntax comes to the rescue. See the code below.

const nums = [2, 5, 3, 4]
const nums2 = [3, 4, 2, 5, 6, 3, 7]

multiplyAll(...nums) // 120
multiplyAll(...nums2) // 15120

We just passed the array after spreading it. ... is a really powerful operator.

Call and Apply methods

The methods call, apply and bind needs a lot of other prerequisites to dive deep into them, but here I'm going to explain it in a way that is relevant to debouncing and later write an article explaining these methods thoroughly.

The call() method calls a function with a given this value and arguments are provided individually. The apply() method is also the same, the only difference is that it accepts arguments as an array.

function getName(){
    console.log(this.name)
}

getName()
// undefined

In the above getName() function we're accessing this inside getName(), the value of this inside any function depends on how the function is invoked. If the function is a method of a class and invoked with the object of that class then this is set to that object inside the method, and when the function is called without any object then the value of this is the global object which is window object in the browser, that's why the value of this.name is undefined as there's no name property in window.

class Animal{
    constructor(animalType){
        this.type = animalType
    }

    getAnimalType(){
        console.log(this.type)
    }
}

animal = new Animal("Lion")
animal.getAnimalType() // Lion

In the above code, we're able to access the "Lion" inside getAnimalType method because getAnimalType() method is called with an object of the same class defining the method, so this inside getAnimalType() is the object animal itself, hence we can access the property type from the animal object inside getAnimalType().

You might think, why not just create an object with name property and call the function with the object and access the object inside the function as this; but it's not possible to do that.

function getName(){
    console.log(this.name)
}

obj1 = { name : 'javascript' }
obj1.getName() // TypeError: obj1.getName is not a function

In the above code, we just created a random object obj1 with a name property and tried to call the function getName() with it. This throws an error because obj1 is not an object from any class and getName() is not a method of any class.

By using call() and apply(), we can get this kind of behaviour from any object and function which is not a part of any class.

function getName(){
    console.log(this.name)
}

obj1 = { name : 'Javascript' }

getName.call(obj1)    // Javascript
getName.apply(obj1)   // Javascript

In the above code, we're calling the function getName() with obj1 by using call and apply, so it simply sets the context or this inside the function to obj1 and hence we can access the name property of the object obj1. The condition here is that the object we're passing in call() or apply() must have the property which we're accessing.

Now, what should we do if the function getName() accepts some arguments. We can't just pass them because we can't call getName(arg1, arg2, ...) like this.

Syntax of call - functionName.call(context, arg1, arg1...)Syntax of apply - functionName.apply(context, [arg1, arg2, ...])

function getName(age, hobby){
    console.log(this.name, age, hobby)
}

obj1 = { name: 'Javascript' }

getName.call(obj1, 40, 'swimming')      // Javascript 40 swimming
getName.apply(obj1, [40, 'swimming'])   // Javascript 40 swimming

This is the way to pass arguments to getName() by using call() and apply().
The difference is that call() accepts arguments one-by-one and apply() accepts them as an array.

Here's an illustration -

We have understood all the prerequisites for debouncing, now let's understand the concept of debouncing.

Debouncing

// Logic of debounce
function debounce(callbackFn, delay) {
    let timerId = null

    function debouncedFn(...args) {
        clearTimeout(timerId)
        timerId = setTimeout(() => {
            callbackFn.apply(null, args)
        }, delay)
    }
    return debouncedFn
}

The above debounce() function accepts a callback function callbackFn() and delay as arguments and calls the callbackFn() after the delay. callbackFn() is the expensive function which we want to avoid calling frequently. So instead of passing the expensive function directly to event listeners with frequent event fires, we pass the debounced version of the function to the event listeners.
Example -

// Hypothetical expensive function
function someExpensiveFunction(){
    console.log("Hello World")
}

window.addEventListener('resize', someExpensiveFunction)

If we pass someExpensiveFunction() directly to the event listener which listens to resize event, then it'll be called every time the resize event fires.

When running the code in console, it executes someExpensiveFunction() 135 times (it can be any high value for your case). This is very bad if you're running something expensive inside the function.

In the above code, resize event is fired 135 times, but we don't want to call the someExpensiveFunction() frequently like this.

The above image shows what is happening currently.

Let's understand how debounce() function solves the issue of calling someExpensiveFunction() every time resize event fires.

// Logic of debounce
function debounce(callbackFn, delay) {
    let timerId = null

    function debouncedFn(...args) {
        clearTimeout(timerId)
        timerId = setTimeout(() => {
            callbackFn.apply(null, args)
        }, delay)
    }
    return debouncedFn
}

// Hypothetically expensive function
function someExpensiveFunction() {
    console.log("Hello World")
}

// Debounced version of someExpensiveFunction with delay of 500ms
const debouncedFunction = debounce(someExpensiveFunction, 500)

// Setting debouncedFunction in event listener
window.addEventListener('resize', debouncedFunction)

We get a debounced version of someExpensiveFunction() as debouncedFunction() and pass it to the event listener. This calls debouncedFunction() every time the resize event fires, but it doesn't call someExpensiveFunction() every time.

Let's go step by step -

  • We pass someExpensiveFunction() and delay of 500 milliseconds to debounce() function and saves the returned debounced version of someExpensiveFunction() in debouncedFunction().

  • Attach the debouncedFunction() to the event listener with resize event.

  • Understand properly that it's not calling the debounce() function 5 times, it's calling the debouncedFn() which is returned from debounce() and saved as debouncedFunction(). The debounce() function is called only once in the beginning.

  • On resizing the browser window, resize event will be triggered too many times and call debouncedFunction(). Let's assume it invoked debouncedFunction() 5 times frequently.

  • The first time debouncedFunction is called, timerId is null. clearTimeout() is called with null, which does nothing. Then it set a timer to execute the callbackFn using apply() (someExpensiveFunction is passed as callbackFn)after a delay of 500ms and set the id of setTimeout to timerId variable.

  • We don't call the callbackFn() directly and use apply() because of the context's issue. Imagine if we had some object to reference as this inside the callbackFn(), then the value this would be the global object inside the function because we are not the ones calling the callbackFn(), setTimeout() is calling the function later at some point in time. In our case, the context is nothing so we passed null to avoid making this a global object.

  • Second call of debouncedFunction() happened immediately before 500ms has passed. Before setting the timer to call the callbackFn(), it checked if there's already something in the line to be executed. Since the call is happening before 500 ms has passed, the timer has not finished by executing the callbackFn, it'll find a timer with a timerId and clear the timer with clearTimeout(). Then sets a new fresh timer to call the callbackFn() with fresh delay of 500ms.

  • The same sequence of events happens in the third, fourth and fifth calls of debouncedFunction(). But after the fifth call of debouncedFunction(), there are no further calls, the last timer is not cleared and executes its callbackFn() which is ultimately someExpensiveFunction().

  • In this whole process, we called debouncedFunction() 5 times but called someExpensiveFunction() only once. We saved resources and avoided calling the expensive function too many times.

See the image below for a visual understanding

I hope, I explained debouncing throughly.

Feel free to connect me on Twitter for feedback, ideas, suggestions, etc.

Thank You so much for reading ๐Ÿ˜Š!

ย