How to Correctly Debounce and Throttle Callbacks in React

image

When a React component handles bursting events like window resize, scrolling, user typing into an input, etc. — it’s wise to soften the handlers of these events.

Otherwise, when the handlers are invoked too often you risk making the using lagging or plane unresponsive for a few seconds. Fortunately, debouncing and throttling techniques can help you tenancy the invocation of the event handlers.

In this post, you’ll learn how to correctly use React hooks to wield debouncing and throttling techniques to callbacks in React.

If you’re unfamiliar with debounce and throttle concepts, I recommend checking Debouncing and Throttling Explained Through Examples.

1. The callback without debouncing

Let’s say that a component <FilterList> accepts a big list of names (at least 200 records). The component has an input field where the user types a query and the names are filtered by that query.

Here’s the first version of <FilterList> component:

import { useState } from 'react';

export function FilterList({ names }) {
  const [query, setQuery] = useState('');

  let filteredNames = names;

  if (query !== "") {
    filteredNames = names.filter((name) => {
      return name.toLowerCase().includes(query.toLowerCase());
    });
  }

  const changeHandler = event => {
    setQuery(event.target.value);
  };

  return (
    <div>
      <input 
        onChange={changeHandler} 
        type="text" 
        placeholder="Type a query..."
      />
      {filteredNames.map(name => <div key={name}>{name}</div>)}
    </div>
  );
}

Try the demo.

When typing the query into the input field, you can notice that the list gets filtered for every introduced character.

For example, if you type char by char the word Michael, then the component would exhibit flashes of filtered lists for the queries M, Mi, Mic, Mich, Micha, Michae, Michael. However, the user would need to see just one filter result: for the word Michael.

Let’s modernize the process of filtering by applying 300ms time debouncing on the changeHandler callback function.

2. Debouncing a callback, the first attempt

To debounce the changeHandler function I’m going to use the lodash.debounce package. You can use any other library at your will, or plane write the debounce function by yourself.

First, let’s squint at how to use the debounce() function:

import debounce from 'lodash.debounce';

const debouncedCallback = debounce(callback, waitTime);

debounce() function accepts the callback treatise function, and returns a debounced version of that function.

When the debounced function debouncedCallback gets invoked multiple times, in bursts, it will invoke the callback only without waitTime has passed without the last invocation.

The debouncing fits nicely to soften the filtering inside the <FilterList>: let’s debounce changeHandler to 300ms wait time.

The only problem with applying debouncing to changeHandler inside a React component is that the debounced version of the function should remain the same between component re-renderings.

The first tideway would be to use the useCallback(callback, dependencies) that would alimony one instance of the debounced function between component re-renderings.

import { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';
export function FilterList({ names }) {
  const [query, setQuery] = useState("");

  let filteredNames = names;

  if (query !== "") {
    filteredNames = names.filter((name) => {
      return name.toLowerCase().includes(query.toLowerCase());
    });
  }

  const changeHandler = event => {
    setQuery(event.target.value);
  };

  const debouncedChangeHandler = useCallback(    debounce(changeHandler, 300)  , []);
  return (
    <div>
      <input 
        onChange={debouncedChangeHandler}         type="text" 
        placeholder="Type a query..."
      />
      {filteredNames.map(name => <div key={name}>{name}</div>)}
    </div>
  );
}

Try the demo.

debounce(changeHandler, 300) creates a debounced version of the event handled, and useCallback(debounce(changeHandler, 300), []) makes sure to return the same instance of the debounced callback between re-renderings.

Note: the tideway moreover works with creating throttled functions, e.g. useCallback(throttle(callback, time), []).

Open the demo and type a query: you’ll see that the list is filtered with a wait of 300ms without the last typing: which brings a softer and largest user experience.

However… this implementation has a small performance issue: each time the component re-renders, a new instance of the debounced function is created by the debounce(changeHandler, 300).

That’s not a problem regarding the correctness: useCallback() makes sure to return the same debounced function instance. But it would be wise to stave calling debounce(...) on each rendering.

Let’s see how to stave creating debounced functions on each render in the next section.

3. Debouncing a callback, second attempt

Fortunately, using useMemo() vaccinate as an volitional to useCallback() is a increasingly optimal choice:

import { useState, useMemo } from 'react';
import debounce from 'lodash.debounce';

export function FilterList({ names }) {
  const [query, setQuery] = useState("");

  let filteredNames = names;

  if (query !== "") {
    filteredNames = names.filter((name) => {
      return name.toLowerCase().includes(query.toLowerCase());
    });
  }

  const changeHandler = (event) => {
    setQuery(event.target.value);
  };

  const debouncedChangeHandler = useMemo(    () => debounce(changeHandler, 300)  , []);
  return (
    <div>
      <input
        onChange={debouncedChangeHandler}
        type="text"
        placeholder="Type a query..."
      />
      {filteredNames.map(name => <div key={name}>{name}</div>)}
    </div>
  );
}

Try the demo.

useMemo(() => debounce(changeHandler, 300), []) memoizes the debounced handler, but moreover calls debounce() only during initial rendering of the component.

This tideway moreover works with creating throttled functions: useMemo(() => throttle(callback, time), []).

If you unshut the demo, you’d see that typing into the input field is still debounced.

4. Be shielding with dependencies

If the debounced handler uses props or state, to stave creating stale closures, I recommend setting up correctly the dependencies of useMemo():

import { useMemo } from 'react';
import debounce from 'lodash.debounce';

function MyComponent({ prop }) {
  const [value, setValue] = useState('');
  
  const eventHandler = () => {
      };

  const debouncedEventHandler = useMemo(() => {
    () => debounce(eventHandler, 300)
  }, [prop, stateValue]);  
  
}

Properly setting the dependencies guarantees refreshing the debounced closure.

5. Conclusion

A good way to create debounced and throttled functions, to handle often happening events, is by using the useMemo() hook:

import { useMemo } from 'react';
import debounce from 'lodash.debounce';
import throttle from 'lodash.throttle';

function MyComponent() {
  const eventHandler = () => {
    
  };

  const debouncedEventHandler = useMemo(() => {    () => debounce(eventHandler, 300)  }, []);
  const throttledEventHandler = useMemo(() => {    () => throttle(eventHandler, 300)  }, []);  
  
}

If the debounced or throttled event handler accesses props or state values, do not forget to set the dependencies treatise of useMemo(..., dependencies).

What events, in your opinion, worth debouncing and throttling?

Comments are closed.