- Motivation
- What is debouncing?
- Controlled and Uncontrolled components
- Debouncing Controlled components
useDebounce
hook- Real-life use cases
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.
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
Top comments (1)
First off thanks for the tutorial, exactly what I am looking for :)
Looking at the debouncing uncontrolled sandbox version. Would you not expect to see the user details on a partial match?
For example, for Leanne Graham's details, when typing just 'Leanne' you would be presented with full details rather than an empty object?
I tested this out with a few amends so that on a match the full name is passed to the jsonplaceholder API GET request instead:
First of all I changed the
const availableUsers
boolean toconst matchedName
and replacedarray.some
witharray.find
which returns the full name.Then
if(!matchedName) { ... }
to set the error, and loading flag.And finally inside of the try catch block, changed the fetch url to
?name=${encodeURIComponent(matchedName)}
This then returned the full details on a partial match.
Maybe I am wrong on this.