Debouncing in React 19: Correct Hooks, useDeferredValue, and Cancelling Stale Requests
Quick answer
Debouncing delays running a function until input stops for a set interval, so a search box makes one API call instead of one per keystroke. In React 19, debounce a value with a useDebouncedValue hook (useState + a setTimeout in useEffect that you clear on every change), then trigger the fetch from an effect on the debounced value. Use useDeferredValue/useTransition instead when the expensive work is rendering (CPU-bound), not a network call — they keep the UI responsive but do not reduce requests. Cancel stale in-flight fetches with AbortController.
Short answer: Debouncing delays a function until the user stops interacting for a set interval, so a search box fires one API call instead of one per keystroke. In React 19, debounce the value with a small useDebouncedValue hook and run the request from an effect on that value. Reach for useDeferredValue/useTransition only when the expensive work is rendering, not a network call.
Handling fast, repeated user input — typing in a search box, dragging a slider, autosaving a draft — is a classic React performance question, and a classic interview question. The naive approach fires a handler on every keystroke; debouncing collapses that burst into a single call once the user pauses.
The catch is that React's render model breaks most copy-pasted debounce snippets. This guide covers the version that actually works, and the React 19 tools people often reach for by mistake.
The mental model
Debounce wraps a function so that calls made within delay milliseconds of each other are coalesced: each new call cancels the pending one and starts the timer again. The wrapped function only runs once the calls stop for a full delay window.
type: t-t-t-t--------(pause)--> runs once, here
└ delay ┘
Two things make this tricky in React specifically:
- Every render creates new function identities. A debounce helper called in the component body is rebuilt each render, so its internal timer resets constantly and never fires.
- Closures capture stale state. A debounced callback created once can "remember" props/state from the render it was created in.
The cleanest way to sidestep both is to debounce the value, not the callback.
The correct hook: useDebouncedValue
import { useEffect, useState } from "react"
export function useDebouncedValue<T>(value: T, delay = 400): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id) // cancel the pending update on every change
}, [value, delay])
return debounced
}The input stays fully controlled (typing feels instant), while debounced only catches up delay ms after the user stops. Now compose it with a fetch effect:
function SearchBox() {
const [query, setQuery] = useState("")
const debouncedQuery = useDebouncedValue(query, 400)
const [results, setResults] = useState<Result[]>([])
useEffect(() => {
if (!debouncedQuery) return
const controller = new AbortController()
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name !== "AbortError") throw err // ignore intentional cancels
})
return () => controller.abort() // cancel the stale request
}, [debouncedQuery])
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
/>
)
}This is the pattern interviewers are looking for: instant typing, one request per pause, and — the part most tutorials skip — cancellation of the previous in-flight request so a slow earlier response can't land after and overwrite newer results (a real race-condition bug).
When you actually need to debounce a callback
Sometimes you can't debounce a value (e.g. a resize or analytics handler). Then you must memoise the debounced function and read the latest callback through a ref:
import { useEffect, useMemo, useRef } from "react"
export function useDebouncedCallback<A extends unknown[]>(
fn: (...args: A) => void,
delay = 400
) {
const fnRef = useRef(fn)
useEffect(() => {
fnRef.current = fn // always call the freshest closure — no stale state
})
return useMemo(() => {
let id: ReturnType<typeof setTimeout>
const debounced = (...args: A) => {
clearTimeout(id)
id = setTimeout(() => fnRef.current(...args), delay)
}
debounced.cancel = () => clearTimeout(id)
return debounced
}, [delay]) // stable identity across renders
}The useMemo gives the debounced function a stable identity so its timer survives re-renders; the useRef keeps it calling the latest fn so it never fires with stale props or state. Missing either one is the single most common bug in this area.
The React 19 trap: useDeferredValue and useTransition are not debounce
React 18/19 added concurrency hooks that look like a replacement — and interviewers love to probe whether you know the difference.
useDeferredValue and useTransition optimise rendering (CPU-bound work). They keep the UI responsive while an expensive render catches up — but they still run on every keystroke. They do not reduce network requests. Debounce is an I/O optimisation. These solve different problems.
Use useDeferredValue when the expensive work is filtering/rendering a large in-memory list:
function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("")
const deferredQuery = useDeferredValue(query) // input stays snappy
const filtered = useMemo(
() => items.filter((i) => i.name.includes(deferredQuery)),
[items, deferredQuery]
)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={filtered} />
</>
)
}Here there is no network call, so there is nothing to debounce — you just don't want the expensive filter/render to block typing. That's exactly what useDeferredValue is for.
| Tool | Optimises | Reduces API calls? | Use for |
|---|---|---|---|
| Debounce | I/O — how often a function runs | ✅ Yes | Search-as-you-type, autosave, validation |
| Throttle | I/O — rate of a continuous handler | ✅ Caps rate | Scroll, resize, mousemove, drag |
| useDeferredValue | Rendering (CPU) | ❌ No | Filtering/rendering large in-memory lists |
| useTransition | Rendering (CPU) | ❌ No | Marking a state update as non-urgent |
Common interview traps
- Recreating the debounced function each render — the timer resets every render and never fires. Memoise it.
- Stale closures — a debounced callback captured once reads old state. Route through a ref.
- Debouncing the wrong thing — debouncing the render/effect but still firing the request on every keystroke.
- Using
useTransitionfor a network call — transitions are for CPU-bound rendering, not I/O; it won't reduce requests. - Forgetting cancellation — without
AbortController, an earlier slow response can overwrite a newer one. - Cleaning up on unmount — a pending timer that fires after unmount calls
setStateon a gone component.
Interviewer follow-ups you should expect
- "How is this different from throttle?" — Debounce waits for a pause and runs once; throttle runs at most once per interval during continuous activity. See our throttle implementation.
- "Implement debounce in plain JavaScript, no library." — See the vanilla
debouncewalkthrough; the timer/closure mechanics are the same, and rely on howsetTimeoutand the event loop schedule work. - "What delay would you pick?" — 200–400 ms for search feels responsive; longer for expensive operations. There's no universal number — it's a UX/cost trade-off you should be able to justify.
- "How do you avoid out-of-order responses?" — Cancel with
AbortController, or track a request id and ignore stale ones.
Senior-level notes
At scale, debounce is only half the story. Pair it with request cancellation (above), caching of recent queries so backspacing doesn't refetch, and a minimum query length so you don't hit the API for a single character. On the backend, debouncing on the client does not remove the need for rate limiting — a scripted client ignores your timer. Treat client debounce as a UX and cost optimisation, never as a correctness or security boundary.
Related guides
- Implement
debouncein vanilla JavaScript - Implement
throttleand when to choose it over debounce - How the event loop schedules
setTimeout
Sources
Key takeaways
- •Debounce reduces how often a function runs (fewer API calls); it is an I/O optimisation.
- •useDeferredValue and useTransition keep rendering responsive (CPU work) but do NOT reduce network requests.
- •Debounce the value, not the callback — a useDebouncedValue hook composes cleanly with a fetch effect.
- •A debounced callback must be memoised and read the latest function via a ref, or it goes stale or resets every render.
- •Always cancel the previous in-flight request with AbortController so a slow earlier response can't overwrite a newer one.
Frequently asked questions
What is the difference between debounce and throttle in React?
Debounce waits until activity stops for a set interval and then runs once — ideal for search-as-you-type. Throttle runs at most once per interval during continuous activity — ideal for scroll, resize, or mousemove handlers that must keep firing but at a capped rate.
Does useDeferredValue replace debouncing for search inputs?
No. useDeferredValue keeps the UI responsive while an expensive render (like filtering a large in-memory list) catches up, but it still recomputes on every keystroke. If each keystroke would trigger a network request, you still need debounce to reduce the number of requests.
Why does my debounced function not work inside a React component?
It is almost always recreated on every render. If you call a debounce helper directly in the component body, each render makes a brand-new timer, so the delay never accumulates. Memoise the debounced function (useMemo) and read the latest callback through a ref to avoid both the reset and a stale closure.
Software Engineering Leader & Technical Author · Updated August 26, 2026