DEV Community

Cover image for Debounce onChange callback using a custom React hook
Nikita Popov
Nikita Popov

Posted on

Debounce onChange callback using a custom React hook

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:

Image description

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)} />;
}
Enter fullscreen mode Exit fullscreen mode

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)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

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)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

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)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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)} />;
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)