Debounce onChange callback using a custom React hook
Nikita Popov
Posted on February 11, 2023
Intro
Let's say you have a search component with a suggest. Suggest items are fetched as you type. You don't want to fetch items on every keystroke, you want to fetch when the user stops typing. How to do this? You probably want to use a technique called 'debouncing'.
What is debouncing?
Debouncing is a technique to limit the rate at which a function is being called. It is commonly used to improve performance and user experience when working with events that trigger rapid function calls.
One common example of when debouncing is needed is when dealing with user inputs, such as typing in a search field. If you were to make a server request for every character that the user types, it could lead to a large number of unnecessary requests and slow down the app.
In this article I will explain how to reduce number of network requests using debouncing. We'll also extract debouncing logic into a custom React hook which can be reused.
Example
Let's take a look at a practical example. Below there's a form for searching a movie by its name. Movie names are fetched from server (in this example we'll be using a mock instead of a real server, but it doesn't make much difference). Try searching 'godfather' in the input below, notice the output in the console:
As you see, the app is making a server request after each keystroke:
Let's see how we can improve this!
Step 1: add setTimeout
Let's take a look at our Input component. Currently it renders the value
provided by it's parent component, and calls onChange
every time user changes the value:
function Input({ value, onChange }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
We don't want to call onChange
instantly, let's try to wrap it in a setTimeout
:
function Input({ value, onChange }) {
// This callback calls 'onChange' after a timeout
const debouncedOnChange = (val) => {
setTimeout(() => onChange(val), 1000);
};
return (
<input value={value} onChange={(e) => debouncedOnChange(e.target.value)} />
);
}
Give it a try:
This clearly adds a delay, but doesn't work as expected! Notice how user input is delayed. Looks like onChange
should be called with a delay, but input value should update instantly, without an delay. How can we achieve this?
Step 2: add local state
We can add local state to our component to store current user input. We'll update local state instantly, and call onChange
with a delay. Here's what it might look like:
function Input({ value, onChange }) {
// Store user input in local state. Initial value is provided by parent component
const [localValue, setLocalValue] = useState(value);
const debouncedOnChange = (val) => {
// Set local value instantly
setLocalValue(val);
// Set parent value after a delay
setTimeout(() => onChange(val), 1000);
};
return (
<input value={localValue} onChange={(e) => debouncedOnChange(e.target.value)} />
);
}
It works great! But looks like it still makes a network request on every key stroke, just the requests are delayed now. Let's fix this!
Step 3: add clearTimeout
If user changes input during the timeout, we want to cancel scheduled onChange
call and schedule another one, with a new input value. Each time we call setTimeout
, it returns a unique number called timeout id. We can pass this number to clearTimeout
to cancel the scheduled timeout and then schedule a new one. To store timeout id we can use useRef
hook. Here's what it can look like:
function Input({ value, onChange }) {
const [localValue, setLocalValue] = useState(value);
// Stores reference to timeout id
const timeoutRef = useRef(null);
const debouncedOnChange = (val) => {
// Clear timeout if there was one pending
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set local value instantly
setLocalValue(val);
// Schedule `onChange` call and store timeout id in the ref
timeoutRef.current = setTimeout(() => onChange(val), 1000);
};
return (
<input
value={localValue}
onChange={(e) => debouncedOnChange(e.target.value)}
/>
);
}
Brilliant! It does the job!
Step 5: extracting to a custom hook
Notice how bloated Input
component has become. Let's extract debounce related logic to a custom hook. This way our component code will be shorter and easier to maintain, and debounce logic can be reused. Here's what it might look like:
// This custom hook can transform any callback to a debounced version
function useDebounce(onChange, duration) {
const timeoutRef = useRef(null);
const onEdit = useCallback(
(val) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => onChange(val), duration);
},
[duration, onChange]
);
return onEdit;
}
function Input({ value, onChange }) {
const [text, setText] = useState(value);
// Custom hook accepts two arguments: original onChange callback, and debounce delay. Its return value is a new, debounced callback
const debouncedOnChange = useDebounce(onChange, 1000);
const onEdit = (val) => {
setText(val);
debouncedOnChange(val);
};
return <input value={text} onChange={(e) => onEdit(e.target.value)} />;
}
Here you can play around with the final version
Conclusion
We have introduced debouncing to our component to limit number of network requests. We've 'packed' debounce code into a custom hook, so that it can be reused elsewhere.
Thank you for following my tutorial!
Cover photo by Wengang Zhai on Unsplash
Posted on February 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.