Debouncing a React input should not require a dependency
Search inputs have a funny way of looking finished before they touch the network.
You start with a controlled input, store the value in state, and filter a list while the user types. Nice and simple.
Then the list moves behind an API.
Suddenly every keypress does work. Type r, request. Type re, request. Type rea, request. Before you've finished typing react, the server has already answered four questions nobody meant to ask.
My first instinct used to be: fine, install a debounce helper and move on.
That isn't a terrible instinct. Libraries like lodash have solved this problem well. But for the common React version of it, "wait a moment before using this value", a dependency can feel like a lot for one timeout and one cleanup function.
React already gives us the pieces.
The input should stay fast. The decision can wait.
The noisy version
Here's the straightforward implementation:
jsximport { useState } from 'react'function UserSearch({ onSearch }) {const [query, setQuery] = useState('')function handleChange(event) {const nextQuery = event.target.valuesetQuery(nextQuery)onSearch(nextQuery)}return (<inputtype="search"value={query}onChange={handleChange}placeholder="Search users..."/>)}
There is nothing wrong with this code. If onSearch filters a small array in memory, ship it.
If onSearch calls an API, updates the URL, writes to storage, or recalculates something expensive, the application now cares about every half-typed value. rea probably isn't a real search query. It's just react on the way past.
The useful move is to separate two things that are easy to mix together:
- what the user is typing right now
- what the application should react to
Those values are related, but they don't need to move at the same speed.
The almost-fix
The tempting version is to put a timeout inside the change handler:
jsxfunction handleChange(event) {const nextQuery = event.target.valuesetQuery(nextQuery)setTimeout(() => {onSearch(nextQuery)}, 300)}
This looks right for about thirty seconds.
Then you type quickly and realize nothing got cancelled. We scheduled every search. The request for r still happens. So does re. So does rea. They just happen late, which is harder to reason about.
We need to clear the previous timeout whenever the value changes again.
And in React, "do something after a value changes, then clean it up before doing it again" is exactly what an effect is for.
A small debounce hook
Here's the whole thing:
jsximport { useEffect, useState } from 'react'function useDebouncedValue(value, delay) {const [debouncedValue, setDebouncedValue] = useState(value)useEffect(() => {const timeout = setTimeout(() => {setDebouncedValue(value)}, delay)return () => {clearTimeout(timeout)}}, [value, delay])return debouncedValue}
Every time value changes, the hook starts a timer.
If value changes again before the timer finishes, React runs the cleanup function and clears the old timeout. Only the latest value survives long enough to become debouncedValue.
That's the whole trick. A value, a delay, and a cleanup function.
Using it in a search input
Now the component has two values:
jsxconst [query, setQuery] = useState('')const debouncedQuery = useDebouncedValue(query, 300)
query belongs to the input. It should update on every keystroke.
debouncedQuery belongs to the work we don't want to repeat too eagerly.
Put together, that looks like this:
jsximport { useEffect, useState } from 'react'function UserSearch({ onSearch }) {const [query, setQuery] = useState('')const debouncedQuery = useDebouncedValue(query, 300)useEffect(() => {onSearch(debouncedQuery)}, [debouncedQuery, onSearch])return (<inputtype="search"value={query}onChange={(event) => setQuery(event.target.value)}placeholder="Search users..."/>)}
Notice what didn't change: the input itself isn't debounced.
Typing still feels immediate because the input uses query. The expensive part uses debouncedQuery, which only changes after 300 milliseconds of quiet.
That separation is the point.
A more realistic example
Let's wire it to a small API search:
jsxfunction UserSearch() {const [query, setQuery] = useState('')const [users, setUsers] = useState([])const debouncedQuery = useDebouncedValue(query, 300)useEffect(() => {if (debouncedQuery === '') {setUsers([])return}async function searchUsers() {const params = new URLSearchParams({ q: debouncedQuery })const response = await fetch(`/api/users?${params}`)const data = await response.json()setUsers(data)}searchUsers()}, [debouncedQuery])return (<><inputtype="search"value={query}onChange={(event) => setQuery(event.target.value)}placeholder="Search users..."/><ul>{users.map((user) => (<li key={user.id}>{user.name}</li>))}</ul></>)}
Now the user can type freely, and the API only hears from us when the query settles. We get the behavior without pulling in a package or hiding the timing inside a helper someone has to debug later.
One annoying edge case
Debouncing reduces the number of requests, but it doesn't guarantee that responses arrive in the same order they were sent.
On a fast connection, you may never notice. On a slow one, an older request can finish after a newer request and overwrite the results. You type the right thing, see the right results, and then watch the wrong results replace them a moment later.
We can guard against that with the same cleanup idea:
jsxuseEffect(() => {if (debouncedQuery === '') {setUsers([])return}let ignore = falseasync function searchUsers() {const params = new URLSearchParams({ q: debouncedQuery })const response = await fetch(`/api/users?${params}`)const data = await response.json()if (!ignore) {setUsers(data)}}searchUsers()return () => {ignore = true}}, [debouncedQuery])
When debouncedQuery changes, React cleans up the previous effect. If the previous request finishes later, it no longer gets to update state.
This doesn't cancel the request itself. For that, use AbortController. But for many small search fields, ignoring stale responses is enough to keep the UI honest.
When to use this
Search boxes. Wait until the user pauses before asking the server for results.
Filter panels. Let someone change a few controls before recalculating an expensive list.
Autosave fields. Save after typing settles instead of storing every half-written sentence.
URL updates. Sync query parameters without filling browser history with every intermediate keystroke.
The pattern is always the same: keep the interface immediate, delay the side effect.
When not to use this
Don't debounce every input by default.
If the work is cheap and local, keep it instant. A checkbox that toggles a small section doesn't need a timeout. Neither does a plain controlled input.
Debouncing helps when the next step is noisy, expensive, or external. API calls. Large filters. Autosave. Analytics. Things where a short pause makes the application calmer.
And if you need leading calls, trailing calls, cancellation, flushing, or a maximum wait time, use a real debounce utility. Libraries exist for a reason.
Most inputs don't need all of that. Most inputs just need: "please wait 300 milliseconds before treating this as real."
For that, this hook is enough.
The useful part
A debounce is usually explained as a timing trick. In React, I find it more useful as a modeling trick.
There is the value the user is editing.
And there is the value the application believes.
Those two values are usually the same. When the next step is expensive, external, or noisy, giving them a little distance makes the code easier to reason about.
No dependency, no function identity puzzle.
Just a value that waits a moment before the rest of the app believes it.