InterviewsVector

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:

  1. 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.
  2. 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.

ToolOptimisesReduces API calls?Use for
DebounceI/O — how often a function runs✅ YesSearch-as-you-type, autosave, validation
ThrottleI/O — rate of a continuous handler✅ Caps rateScroll, resize, mousemove, drag
useDeferredValueRendering (CPU)❌ NoFiltering/rendering large in-memory lists
useTransitionRendering (CPU)❌ NoMarking 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 useTransition for 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 setState on 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 debounce walkthrough; the timer/closure mechanics are the same, and rely on how setTimeout and 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.

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.

By Mohammad Wasi

Software Engineering Leader & Technical Author · Updated August 26, 2026


Related Posts