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
andthrottle
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
}
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
}
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
}
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
: Sincerefs
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 theref
. 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
}
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)
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
_
fromlodash
will make the bundle size very large and on the front end this matters.True.
If adding a library is expensive, these logics can be moved to custom hooks