Back to archive

How to write your own debounce function

I suppose you already know why we use debounce, but just as a little refresher, here’s my own definition: “we use debounce when we do not want to fire a function as soon as it gets called, but instead, we want to handle the cases when the function gets called multiple times in a short timeframe and have our program run only once”.

If you do not like my definition here’s the MDN one, it’s not long, and I advise you to read it.

This article focuses on how to write such a function yourself and why it works the way it does.

First and foremost, debounce is a function that returns another function, the debounced version of the function we pass as a first argument. We define this function to tell our application that we need a new one that does the same things as the original, like reading/writing to our database or performing other complex operations. However, we want to fire it only when the wait time has passed since our last call.

Let’s make things real: imagine Google’s autosuggesting feature. While it seems to suggest keywords as we time, the engineers at Google cannot afford to query their immense database for every keystroke. Instead, they debounce the keyword search that’s read from the input so the app can wait for the user to stop writing and collect all the characters the user has been able to type to make a better guess (suggestion) about what the user is looking for.

Now that you have this image in your mind, let’s start from the function signature.

function debounce(func: Function, wait: number): Function {
	// function body
}

Other implementations of debounce accept even more attributes, but I want to keep it simple, as this will allow us to pass the first challenge of the GFE 75 collection of GreatFrontEnd.

Just to be clear, I am not here to give you the golden ticket that will allow you to pass these challenges; I want you to earn it while understanding the ins and outs of the challenges that I face while completing it on my own.

With that said, as you can see, we just pass two arguments to our debounce function: the func that is the function that we want to debounce, and a wait value that will be the number of milliseconds that we will wait after the last subsequent calls of func gets invoked.

But how does it work?

If you started to think about setTimeout as soon as you read “we do not want to fire as soon as it gets called”, you were right. We need to use this built in method in order to instruct our program about this functionality.

But remember, we also have to clear the timeout if the same function gets called multiple times in the timeframe defined by wait.

That is because while setTimeout is a powerful function, it is not able to handle the clearing by itself. We need to tell it to clear the timeout of the last running function before to fire a new one.

But how do we keep track of the latest timer set?

And here’s where another important concept of JavaScript programming comes into the scene: closures.

I will write more about this kind of function in a separate article. It is important to know now that with closure, you can reference a value generated by a function even when it has terminated its execution.

And that’s what does the trick.

Clearing the timeout

With this knowledge, we know that the body of our debounce function looks like this:

function debounce(func: Function, wait: number): Function {
  // Keep track of the timeout ID
  let timeoutId: number;

  return function(this: any, ...args: any[]){
	// Clear previous timeout by accessing the outer timeout ID
    clearTimeout(timeoutId)

	// Set a new timeout with our function
    timeoutId = setTimeout(
	    // The function that will be executed after wait
    , wait)
  }
}

The function that we return from debounce is the closure we were talking about. It is able to access the timeoutId and clear the scheduled execution of the function in case we have a subsequent call.

Now that we now how to handle the timeout we set for the execution, it’s time to properly talk about how we will execute the function itself.

The proper way to call the func

Knowing the setTimeout signature, my first attempt has been the following:

function debounce2(func: Function, wait: number): Function {
  let timerId: number;

  return function(this: any, ...args: any[]){
    clearTimeout(timerId)

    timerId = setTimeout(func, wait, ...args)
  }
}

As you can see, I leveraged setTimeout’s ability to pass additional arguments to the delayed func by spreading the array we collect during initialization.

Doing so allowed me to pass almost all the tests that GFE has set for this challenge. There was only one test that I was failing, the one that allowed the callback to access the this context.

That’s because most of the time we just use a simple arrow function inside setTimeout, and doing so means that we do not care about this binding as arrow functions do not have their own this.

But the guys over GFE are smart and want you to prepare for the unexpected, that’s why they put the following test:

test('callbacks can access `this`', (done) => {
  const increment = debounce(function (this: any, delta: number) {
    this.val += delta;
  }, 10);

  const obj = {
    val: 2,
    increment,
  };

  expect(obj.val).toBe(2);
  obj.increment(3);
  expect(obj.val).toBe(2);

  setTimeout(() => {
    expect(obj.val).toBe(5);
    done();
  }, 20);
});

As you can see, in this test, we attach the debounced increment function as a method of our obj, and we expect to be able to access this.val and increase it.

While my previous definition had other fallbacks, like the inability to do more besides calling func (like a simple console.log, for example), the biggest one is that we could not bind this to it.

While there are several approaches to solve this issue, I decided to go with the apply direction, rewriting the function call like so:

export default function debounce(func: Function, wait: number): Function {
  let timerId: number;

  return function(this: any, ...args: any[]){
    clearTimeout(timerId)

    timerId = setTimeout(() => {func.apply(this, args)}, wait)
  }
}

This version works well for all tests because, in many cases, this will result in undefined because it will refer to the this value of the debounced function. Since, in almost all tests, the debounced function is just an arrow function that has no knowledge of this our apply call will just use the args we pass to the function.

Like in the following test:

test('uses arguments of latest invocation', (done) => {
    let i = 21;
    
    const increment = debounce((a: number, b: number) => {
        i += a * b;
    }, 10);

    expect(i).toBe(21);
    increment(3, 7);
    increment(4, 5);
    expect(i).toBe(21);

    setTimeout(() => {
	        expect(i).toBe(41);
	        done();
        }, 20);
    });
});

Here, increment() gets called without anything chained on the left side, and since we’re talking about an arrow function, we can safely ignore the this value and collect all the args safely.

Instead, in the previous failing test, we defined the function that we want to debounce with the standard declaration, and after adding it as a method of obj, the function was able to access to this that was represented by the object it was connected to, the obj object to be clear.

Suggestions and conclusion

I hope that this article helped you better understand what happens when you use one of the many debounce functions found in utility libraries like Lodash.

If you want to go the extra mile, you could integrate other options like the ones defined in the Lodash documentation into your debounce.

I prefer to close this article here, though, as I reached my scope in explaining to you how - but especially why - you could solve the first challenge of the GFE 75 path.

If you want to know more, start following me. I’ll keep writing deep-dive articles like the one you are reading about the challenges proposed by the amazing GreatFrontEnd platform.