BlogReactJS

Debounce and cancel in ReactJS (+ useDebounce custom hook)

Written by Codemzy on February 15th, 2023

In the blog post, we'll create a useDebounce custom hook to delay function calls with React's useCallback and a little time travelling magic. We will also cancel our debounced function when the component unmounts or dependencies change.

Some events in ReactJS can run multiple times per second. Like mouse movements and window scrolling. If you need to run a function after an event like this, it's common to debounce the function, to stop it from running too often.

debounce diagram

With debouncing, when you call a function, you give it a delay. For example, if you debounce the function with a delay of 500ms, each time you call the function it will wait 500ms before the function runs. If the same function is called again within 500ms, the function is triggered again, and the previous function call is cancelled.

This means that your function will only run after 500ms when it hasn't been called again.

If the event was a mouse move, the function will only run once per mouse move (500ms after the mouse stops moving).

debounce function infographic

Debouncing is super handy, especially if you need to run expensive functions and don't want the user's browser to hang or become unresponsive.

The custom debounce function

Before we create the React hook, let's start with a JavaScript debounce function. One you can use with any framework or plain JavaScript.

Here's the debounce function I use - but you can also find debounce functions in JavaScript libraries like lodash if you prefer.

// debounce function (defaults wait to .2 seconds)
const debounce = (func, wait = 200) => {
    let timeout; // for the setTimeout function and so it can be cleared
    return function executedFunction(...args) { // the function returned from debounce
        const later = () => { // this is the delayed function
            clearTimeout(timeout); // clears the timeout when the function is called
            func(...args); // calls the function
        };
        clearTimeout(timeout); // this clears the timeout each time the function is run again preventing later from running until we stop calling the function
        timeout = setTimeout(later, wait); // this sets the time out to run after the wait period
    };
};

The debounce function is called debounce (original, I know!), this is a totally custom function and you can call it whatever you like. You pass it a function func and a delay wait (optional, defaults to 200ms).

The debounce function works by setting a timeout, and giving that timeout a function later to run, well, later. The timeout is set to run after the wait time.

But (and this is what makes it a debounce function and not the native setTimeout function), each time the debounced function is called we clearTimeout and then setTimeout again.

Let's see it in action:

// debounce function - runs after 1 second (1000ms)
let debounced = debounce(() => console.log("I'm debounced"), 1000);
// dummy events running every 100ms
setTimeout(debounced, 100); // this won't run
setTimeout(debounced, 200); // this won't run
setTimeout(debounced, 300); // this won't run
setTimeout(debounced, 400); // this will run

Using debounce in React

So I've seen some other useDebounce hooks floating around the interwebs that used useEffect to debounce state updates. The reason I ended up having to create something different is that I had some use cases where I had to debounce before setting the state.

For example, I need to run a function that did some expensive computations before updating the state. So I need to be able to call the debounce function directly from the event.

For the debounce to work, I need to call the same function each time, so the previous call gets cancelled when a new function call is triggered.

For example, this won't work:

const debounceChanges = debounce(function(newChanges) {
    setChanges(complexFunction(newChanges));
}, 500); // every .5 seconds max

// example use
editor.onChanges(() => {
    debounceChanges(editor.content);
});

Every time the component re-renders, a new debounceChanges function gets created. So nothing gets denounced. That's not what we want!

Luckily, we can use the useCallback hook.

useCallback is a React Hook that lets you cache a function definition between re-renders.

- ReactJS useCallback

Perfect, with useCallback, our function will now stay the same between renders.

const debounceChanges = React.useCallback(debounce(function(newChanges) {
    setChanges(complexFunction(newChanges));
}, 500), []); // every .5 seconds max

// example use
editor.onChanges(() => {
    debounceChanges(editor.content);
});

How to cancel the debounce

Now we have our debounce working in ReactJS, and we can be as pleased as punch. But what if you need to cancel a debounce?

I needed to do this when I was triggering an API call 10 seconds after a user last edited some content.

It looked like this:

const debounceSave = React.useCallback(debounce(function() {
    handleSave();
}, 10 * 1000), []); // every 10 seconds max

React.useEffect(() => {
    debounceSave();
}, [changes]); // run when new changes

React.useEffect(() => {
    return () => {
        handleSave(); // save on exit
    }
}, [id]);

If the component unmounts or id changes, I call handleSave() with no debounce. But if that unmount happens after 2 seconds of an update to changes, I need to cancel the future planned handleSave() happening in 8 seconds (due to the debounce call that already happened 2 seconds ago).

Let's tweak the debounce function so that we can cancel it.

// debounce function (defaults wait to .2 seconds)
const debounce = (func, wait = 200) => {
    let timeout; // for the setTimeout function and so it can be cleared
    function executedFunction(...args) { // the function returned from debounce
        const later = () => { // this is the delayed function
            clearTimeout(timeout); // clears the timeout when the function is called
            func(...args); // calls the function
        };
        clearTimeout(timeout); // this clears the timeout each time the function is run again preventing later from running until we stop calling the function
        timeout = setTimeout(later, wait); // this sets the time out to run after the wait period
    };
    executedFunction.cancel = function() { // so can be cancelled
        clearTimeout(timeout); // clears the timeout
    };
    return executedFunction;
};

Now we also return a cancel function that we can call to cancel any delayed function call we have planned. Like a Time Machine!

Here's how we can use it:

const debounceSave = React.useCallback(debounce(function() {
    handleSave();
}, 10 * 1000), []); // every 10 seconds max

React.useEffect(() => {
    debounceSave();
}, [changes]); // run when new changes

React.useEffect(() => {
    return () => {
        handleSave(); // save on exit
        debounceSave.cancel(); // cancel any pending saves
    }
}, [id]);

The useDebounce custom hook

Ok, so now debounce is working in React, and our work is done! Or is it?

You may have already noticed I'm using the debounce function in two places - debounceChanges and debounceSave. And that's just in this one component.

I'm sure I'll find more places I need it, so let's create a custom hook!

useDebounce.hook.js

import React from 'react';

// debounce function (defaults wait to .2 seconds)
const debounce = (func, wait = 200) => {
    let timeout; // for the setTimeout function and so it can be cleared
    function executedFunction(...args) { // the function returned from debounce
        const later = () => { // this is the delayed function
            clearTimeout(timeout); // clears the timeout when the function is called
            func(...args); // calls the function
        };
        clearTimeout(timeout); // this clears the timeout each time the function is run again preventing later from running until we stop calling the function
        timeout = setTimeout(later, wait); // this sets the time out to run after the wait period
    };
    executedFunction.cancel = function() { // so can be cancelled
        clearTimeout(timeout); // clears the timeout
    };
    return executedFunction;
};

// hook for using the debounce function
function useDebounce(callback, delay = 1000, deps = []) {
    // debounce the callback
    const debouncedCallback = React.useCallback(debounce(callback, delay), [delay, ...deps]); // with the delay
    // clean up on unmount or dependency change
    React.useEffect(() => {
        return () => {
            debouncedCallback.cancel(); // cancel any pending calls
        }
    }, [delay, ...deps]);
    // return the debounce function so we can use it
    return debouncedCallback;
};

export default useDebounce;

Perfect, now we can useDebounce whenever we want to use debounce! (see what I did there?!)

Here's how debounceChanges and debounceSave look using the new custom hook.

const debounceChanges = useDebounce(function(newChanges) {
    setChanges(complexFunction(newChanges));
}, 500, []); // every .5 seconds max

const debounceSave = useDebounce(function() {
    handleSave();
}, 10 * 1000, [id]); // every 10 seconds max

And the good thing about the custom hook is I no longer need to worry about cancelling any pending function calls in my component.

The hook takes care of it for me! It cancels delayed functions on dismount or if any dependencies change. In the example above debounceChanges will cancel on dismount, and debounceSave will cancel on dismount or id change.