DEV Community

Cover image for How to debounce and throttle functions with React hooks
Shubham Khatri
Shubham Khatri

Posted on • Edited on • Originally published at betterprogramming.pub

How to debounce and throttle functions with React hooks

Debouncing and throttling are two very common terms that we come across when trying to optimize function calls. They can be very useful for improving the performance of user interactions.

Before jumping into the main implementation, let’s understand the basic concepts of debounce and throttle and their real-life use cases (skip ahead if you are already familiar with these concepts).


What Is debounce?

Debouncing enforces that there is a minimum time gap between two consecutive invocations of a function call.

For example, a debounce interval of 500ms means that if 500ms hasn’t passed from the previous invocation attempt, we cancel the previous invocation and schedule the next invocation of the function after 500ms.

A common application of debounce is a Typeahead.

What Is throttle?

Throttling is a technique with which a function is invoked at most once in a given time frame regardless of how many times a user tries to invoke it.

For example, given a throttle interval of 500ms, if we try to invoke a function n times within 500ms, the function is called only once when 500ms has elapsed from the beginning.

Throttle is commonly used with resize or scroll events.


Using debounce and throttle With Class Components

Before we dive into how we can use throttle/debounce in functional components with Hooks, let’s quickly see how we do it in a class component.

We define the debounced/throttled version of our function in the constructor function, and that is all we need to do.

Note: I am using lodash debounce and throttle functions in this article.

import React from "react";
export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.debouncedOnChange = _.debounce(this.handleChange, 300);
    this.debouncedHandleWindowResize = _.throttle(this.handleWindowResize, 200);
  }
  handleChange = (_, property) => {
    // your logic here
  };

  handleWindowResize = (_, property) => {
    // your resize logic here
  };

  // rest of rendering code
}

Enter fullscreen mode Exit fullscreen mode

Check out the sample demo in the StackBlitz below:


Using debounce and throttle in Functional Components

We shall attempt to try to convert the class-based implementation to a function-based approach.

The first thing that comes to mind is to directly define the debounced and throttled function within the functional component. So let’s try that first:

import React from "react";
import _ from "lodash";
export default function App() {

  const onChange = () => {
    // code logic here
  };
  const handleWindowResize = () => {
    // code logic here
  };
  const debouncedOnChange = _.debounce(onChange, 300);
  const throttledHandleWindowResize = _.throttle(handleWindowResize, 300);

   //rendering code here
}

Enter fullscreen mode Exit fullscreen mode

When we do it this way, on every render cycle of the component, a new instance of the debounced/throttled function is created. Basically, we aren’t calling the same function after each re-render and it doesn’t work as expected, which you can see in the StackBlitz demo below:

So this is definitely not the right way of using debounce/throttle in functional components.


Refining Our Implementation Based on Our Learning

Now that we understand that we do not want multiple instances of our debounced or throttled function to get created after each render cycle, we shall try to optimize it. One way we can do that is by using the useCallback Hook.

According to the React docs on useCallback:

“Pass an inline callback and an array of dependencies. useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed.”

import React, { useState, useEffect, useCallback } from "react";
import _ from "lodash";

export default function App() {
  const [inputValue, setInputValue] = useState("");

  const onChange = () => {
    console.log('inputValue', inputValue);
    // other logic here
  };
  //debounced onChange functin
  const debouncedOnChange = useCallback(_.debounce(onChange, 300), [inputValue]);

  const handleWindowResize = useCallback((_, property) => {
   // logic here
  }, []);

  const throttledHandleWindowResize = useCallback(
    _.throttle(handleWindowResize, 300),
    []
  );

  const handleChange = e => {
    setInputValue(e.target.value);
  };

  useEffect(() => {
    onChange();
    debouncedOnChange();
  }, [inputValue]);

  // other code here
}

Enter fullscreen mode Exit fullscreen mode

In the snippet above, we see that the onChange handler makes use of the enclosing state inputValue. So when we create the memoized debounced function with useCallback, we pass inputValue in the dependency array of useCallback. Otherwise, the values obtained in the function call will be stale values instead of the updated ones due to closures.

We have a problem, though: A new reference of our function only gets created when inputValue changes. However, the input value changes every time we want to call the function, so we will still face the same problem of a new reference getting created. The net result is that our function still doesn’t work as expected.

The throttled function, for its part, doesn’t use any state or enclosing variable and hence works perfectly well with an empty dependency array.

The StackBlitz below shows the same behavior:


Further Optimizing the Approach

We now know that useCallback can help if we are able to create the instance of the debounced or throttled function only on the initial render, so can we solve the problem of stale closures without having to add a dependency to useCallback?

Well, you are in luck. The answer is yes.

There are at least two ways we can solve this problem.

  • Keeping a copy of our state in ref: Since refs are mutated, they aren’t truly affected by closures in the sense that we can still see the updated value even if the reference is old. So whenever we are updating the state, we also update the ref. We shall not go down this path unless it’s a last resort, as it is a bit hacky and involves a lot of state duplication, which isn’t ideal.

  • Pass values as arguments: Instead of relying on closures to use a value, we can pass all the necessary values that our function needs as arguments.

Our code looks like this:

import React, { useState, useEffect, useCallback } from "react";
import _ from "lodash";
export default function App() {
  const [inputValue, setInputValue] = useState("");
  const [debounceValues, setDebounceValues] = useState({
    nonDebouncedFuncCalls: 0,
    debouncedFuncCalls: 0
  });
  const [throttleValues, setThrottleValues] = useState({
    nonThrottledFunctionCalls: 0,
    throttledFuntionCalls: 0
  });

  const onChange = (property, inputValue) => {
    console.log(`inputValue in ${property}`, inputValue);
    setDebounceValues(prev => ({
      ...prev,
      [property]: prev[property] + 1
    }));
  };
  const handleWindowResize = useCallback((_, property) => {
    setThrottleValues(prev => ({
      ...prev,
      [property]: prev[property] + 1
    }));
  }, []);

  const debouncedOnChange = useCallback(_.debounce(onChange, 300), []);
  const throttledHandleWindowResize = useCallback(
    _.throttle(handleWindowResize, 300),
    []
  );

  const handleChange = e => {
    const value = e.target.value;
    setInputValue(value);
    onChange("nonDebouncedFuncCalls", value);
    debouncedOnChange("debouncedFuncCalls", value);
  };

  const onWindowResize = useCallback(e => {
    handleWindowResize(e, "nonThrottledFunctionCalls");
    throttledHandleWindowResize(e, "throttledFuntionCalls");
  }, []);

  useEffect(() => {
    window.addEventListener("resize", onWindowResize);
    return () => {
      window.removeEventListener("resize", onWindowResize);
    };
  }, [onWindowResize]);

  //rest of the rendering code
}

Enter fullscreen mode Exit fullscreen mode

In the code above, we are passing the inputValue as an argument to the debounced function and thus ensuring that it has all the latest values it needs and works smoothly.

Note: Using functional state updates can also help to avoid passing every property — especially if it just has to update the state.

Check out the full working code in the StackBlitz below:

So there we have it. Our debounced and throttled functions now work well with functional components too, and it wasn’t as complicated as we imagined it to be.

Note: Instead of the useCallback, we can also use useMemo, but the main approach logic will remain the same.


Summary

We performed a step-by-step conversion of a class component to a functional component using debounce with React Hooks. These are the key takeaways:

  • We need to use the same instance of the created function as much as possible.

  • Use the useCallback/useMemo Hook to memoize our created functions.

  • To avoid closure issues and also prevent the function from getting recreated, we can pass the values needed by the function as arguments.

  • State updates that need previous values can be implemented using the functional form of setState.

Thank you for Reading

Please do share your suggestions in the comments below. If you liked this article share this with your friends.

Consider following me on Twitter for more tips and trips related to web development.

Top comments (3)

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Totally agreed. Also, if you are going to use lodash, try lodash-es if you use 10+ modules and in all cases only import the actual things you use so that tree shaking can reduce the size of the output. Importing _ from lodash will make the bundle size very large and on the front end this matters.

Collapse
 
shubhamreacts profile image
Shubham Khatri

True.

Collapse
 
shubhamreacts profile image
Shubham Khatri

If adding a library is expensive, these logics can be moved to custom hooks