Debouncing React Controlled Components

Motivation

In my most recent application, I came across the need to debounce some form fields. Every time I had to debounce, it's usually an uncontrolled component. This time, I had to debounce a controlled component. A normal debounce function wouldn't work as expected, so I had to use another method and ended up creating a useDebounce hook for reusability.

What is debouncing?

If you don't know what it is, debounce is usually a set of code that keeps a function from running too many times. You can read more about it in this article. It is usually used for user actions to prevent the user from spamming too many requests to the server. A usual use case is in search or toggle inputs. We listen to the user inputs and only send the result to the server when no more inputs coming in. Let's see some example

/**
 * A basic debounce function.
 * Most implementations you'll see look like this.
 * @params {VoidFunction} callback - A callback function to be called after timeout ends
 * @params {number} timeout - Timeout in milliseconds
 * @returns {VoidFunction} - A function to execute the callback
 */

function debounce(callback, timeout = 500) {
  let timer

  // inner function
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => callback.apply(this, args), timeout)
  }
}

The debounce function sets a timer(500ms in our example), when the inner function is called before the timer ends, we cancel the timer and start over. The callback function is only triggered when the timer ends without being interrupted.

See a detailed implementation on codesandbox

using in our component;

<input
  name="search"
  type="search"
  id="search-input"
  onChange={debounce(handleChange)}
/>

See a detailed implementation on codesandbox

This is an example with an uncontrolled component

Controlled and Uncontrolled components

Controlled

In a React controlled component, the input value is set by the state. The onChange handler listens to input changes and stores the value into the state. The input value is then updated with the value stored in the state.

function Controlled() {
  const [value, setValue] = useState()

  const handleChange = event => {
    setValue(event.target.value)
  }

  const handleSubmit = event => {
    event.preventDefault()
    console.log({ value })
  }

  return (
    <form id="search" onSubmit={handleSubmit}>
      <label htmlFor="search-input">Search</label>
      <input
        id="search-input"
        name="search"
        type="search"
        value={value}
        onChange={handleChange}
      />
      <button type="submit">Search</button>
    </form>
  )
}

Edit on codesandbox

Uncontrolled

In an uncontrolled component, instead of updating the values with the state, you can use a ref to get form values from the DOM. Basically, in an uncontrolled component, we allow the form elements to update their values with the normal HTML form behaviour For example

function UnControlled() {
  const inputRef = useRef(null)

  const handleSubmit = event => {
    event.preventDefault()
    console.log({ value: inputRef.current.value })
  }

  return (
    <form id="search" onSubmit={handleSubmit}>
      <label htmlFor="search-input">Search</label>
      <input ref={inputRef} id="search-input" name="search" type="search" />
      <button type="submit">Search</button>
    </form>
  )
}

The input field is updated by the DOM. We select the input element with our inputRef and then read the value when we need it.

Edit on codesandbox

Debouncing Controlled components

We've already seen how to debounce an uncontrolled component in our first example. You can also see and interact with the example on codesandbox.

The approach used in the example doesn't work for controlled components. Instead of writing a debounce function to debounce our input,

function Controlled() {
  const timerRef = useRef(null) // Store the previous timeout
  const [value, setValue] = useState()
  const [user, setUser] = useState()

  const fetchUserDetails = useCallback(async () => {
    try {
      const [userDetails] = await fetch(`${API}?name=${value}`).then(res =>
        res.json()
      )

      setUserDetails(prevDetails => ({ ...prevDetails, ...userDetails }))
    } catch (error) {
      console.log(error)
    }
  }, [value])

  // Producing the same behaviour as the 'inner function' from the debounce function
  useEffect(() => {
    clearTimeout(timerRef.current) // clear previous timeout

    timerRef.current = setTimeout(() => {
      timerRef.current = null // Reset timerRef when timer finally ends
      fetchUserDetails()
    }, 500)

    return () => clearTimeout(timerRef.current)
  }, [fetchUserDetails])

  const handleChange = event => {
    setValue(event.target.value)
    console.log(event.target.value)
  }

  return (
    <form id="search">
      <label id="search-label" htmlFor="search-input">
        Search for user details
      </label>
      <input
        name="search"
        type="search"
        id="search-input"
        value={value}
        onChange={handleChange}
      />
    </form>
  )
}

Instead of storing the previous timer in a lexical scope, we store it in a ref and then send our request to the server with the useEffect hook. It's a simple implementation but we have one problem. It's not reusable. We need to create a custom hook for this.

useDebounce hook

import { useEffect, useRef } from "react"

/**
 * @callback callbackFunc
 * @param {any[]} args - arguments passed into callback
 */
/**
 * Debounce function to reduce number executions
 * @param {callbackFunc} cb - callback function to be executed
 * @param {number} wait - number of milliseconds to delay function execution
 * @param {any[]} deps - dependencies array
 */
const useDebounce = (cb, wait = 500, deps = []) => {
  const timerRef = useRef(null)

  useEffect(() => {
    clearTimeout(timerRef.current)

    timerRef.current = setTimeout(() => {
      cb.apply(this, args)
    }, wait)

    return () => clearTimeout(timerRef.current)
    /** used JSON.stringify(deps) instead of just deps
      * because passing an array as a dependency causes useEffect 
re-render infinitely
      * @see {@link https://github.com/facebook/react/issues/14324}
      */
    /* eslint-disable react-hooks/exhaustive-deps */
  }, [cb, wait, JSON.stringify(deps)])
}

My implementation isn't perfect and may contain bugs but it works fine for my case. Feel free to improve it and share yours in the comments.

Now we can useDebounce in our component;

function Controlled() {
  const [value, setValue] = useState()
  const [user, setUser] = useState()

  // Debounce our search
  useDebounce(async () => {
    try {
      const [userDetails] = await fetch(`${API}?name=${value}`)
                                                             .then(res => res.json())

      setUserDetails(prevDetails => ({ ...prevDetails, ...userDetails }))
    } catch (error) {
      console.log(error)
    }
  }, 500, [value])

  const handleChange = event => {
    setValue(event.target.value)
    console.log(event.target.value)
  }

  return (
    <form id="search">
      <label id="search-label" htmlFor="search-input">
        Search for user details
      </label>
      <input
        name="search"
        type="search"
        id="search-input"
        value={value}
        onChange={handleChange}
      />
    </form>
  )
}

See detailed implementation on codesandbox

Real-life use cases

I'm currently working on an app. In my app, for each item in the cart, the user can add different sizes and also increment or decrement the quantities of each size. The sizes and quantities are parsed into an object and stored in context before being sent to the server. Demo

While exploring this topic, I created a demo application for validating a sign-up form with an API in real-time.

After writing this article, I found a different approach on usehooks.com to this and I recommend checking it out