← Recipes

阅读中文版本

Debounce Search Input Without Losing Keystrokes or Requests

Debounce the expensive effect, not the input value, cancel in-flight requests on new input, and keep every keystroke responsive.

~25 minfrontendreactux

Task

Wire a typeahead that fires at most one request per pause, cancels stale responses, and shows every character the user typed.

When to use this

A search box fires a request per keystroke: 300% database load, out-of-order responses overwriting newer results, and the input itself lagging because state updates were debounced too.

Prerequisites

  • A search endpoint fast enough for a 250-350ms burst
  • React 18+ (or any UI framework with an AbortController-capable fetch layer)
  • A visible network log to observe request patterns

Steps

1. Debounce the side effect, never the controlled input.

function Search() {
  const [query, setQuery] = useState('');        // updates EVERY keystroke
  const [results, setResults] = useState<Row[]>([]);

  // the effect is debounced; the input is never delayed
  useEffect(() => {
    if (!query.trim()) { setResults([]); return; }
    const t = setTimeout(() => runSearch(query), 300);
    return () => clearTimeout(t);               // cancel pending on change
  }, [query]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Expected output: The input echoes every character instantly while requests fire at most once per 300ms pause; rapid typing sends zero intermediate requests.

2. Cancel in-flight requests so stale responses cannot win.

useEffect(() => {
  if (!query.trim()) { setResults([]); return; }
  const ac = new AbortController();
  const t = setTimeout(async () => {
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: ac.signal,
      });
      const data = await res.json();
      setResults(data.rows);                    // only if not aborted
    } catch (err) {
      if (err.name !== 'AbortError') console.error(err);
    }
  }, 300);
  return () => { clearTimeout(t); ac.abort(); };  // both timer AND request
}, [query]);

Expected output: Typing fast produces one request; every superseded request is aborted at the network layer, so out-of-order overwrites become impossible.

3. Track request provenance so the UI cannot regress.

const [latestId, setLatestId] = useState(0);

const runSearch = async (q: string, id: number) => {
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
  const data = await res.json();
  if (id === latestId) setResults(data.rows);  // guard: am I still current?
};

useEffect(() => {
  const id = ++idRef.current;
  setLatestId(id);
  const t = setTimeout(() => runSearch(query, id), 300);
  return () => clearTimeout(t);
}, [query]);

Expected output: Even if an older request slips through (no AbortController support), the id guard discards its response because a newer search exists.

4. Add trailing-edge semantics and an immediate clear path.

// leading:false, trailing:true is what search wants:
// fire AFTER the pause, never on the first keypress
function useDebouncedEffect(fn: () => void, deps: unknown[], ms = 300) {
  useEffect(() => {
    const t = setTimeout(fn, ms);
    return () => clearTimeout(t);
  }, [...deps, ms]);
}

// clearing should bypass the debounce entirely
onChange={(e) => {
  const v = e.target.value;
  setQuery(v);
  if (v === '') { cancelAll(); setResults([]); }   // instant reset
}}

Expected output: Searches fire after the pause by design, and clearing the box resets instantly instead of showing stale results for another 300ms.

5. Verify with the network log and a scripted burst.

// test: type "checkout" at 80ms/keystroke, then wait
// expected network: exactly 1 request, q=checkout
// type "ch", pause 400ms, type "eckout": exactly 2 requests

// automated check in Playwright
await page.fill('[data-testid=search]', 'checkout');
await page.waitForTimeout(500);
const requests = await page.evaluate(() => window.__requests);
expect(requests).toEqual(['checkout']);   // one, not eight

Expected output: The burst test asserts exactly one request per pause: eight keystrokes produce one request, and the two-phase input produces exactly two.

Troubleshooting

Problem

Input itself feels laggy

Solution

The debounce was wrapped around the state update instead of the effect; move it so the controlled value updates synchronously and only the fetch waits.

Problem

Responses still overwrite each other

Solution

Cancellation is incomplete: clear the timeout AND abort the fetch in the same cleanup, and keep the id guard for transports without AbortController.

Problem

Server cannot handle even debounced bursts

Solution

Raise the delay to 350-500ms, add a minimum query length (2-3 chars), and cache identical queries so repeated typing of the same term is free.

Limitations

  • Debounce trades instant-per-keystroke search for sanity; products demanding literal as-you-type behavior need server-side rate limiting instead.
  • 300ms is a default, not a law; measure typing cadence in your audience and tune, and keep the cancel path airtight at any delay.

Debounce the fetch, not the fingers

The classic typeahead bug is debouncing the wrong thing: the input value lags, keystrokes feel lost, and requests still storm. Debounce only the expensive side effect, cancel everything superseded, and both the typing and the network stay calm.

before (debounce on state):
  keystrokes: c-h-e-c-k-o-u-t
  input echo: lags 300ms behind, chars "lost" visually
  requests: 8 (one per char), out-of-order overwrites

after (debounce on effect + abort):
  keystrokes: c-h-e-c-k-o-u-t (80ms apart)
  input echo: instant, every char visible
  requests: 1, q=checkout, fired 300ms after last key
  aborts: 7 timers cleared, 0 in-flight (none sent)

burst test (Playwright):
  fill("checkout") -> expect requests == ["checkout"]

Two cancellations, one cleanup

A pending timer and an in-flight request are separate things, and the effect cleanup must kill both: clearTimeout stops the not-yet-sent, abort stops the already-sent. Missing either half resurrects the race the debounce was supposed to prevent.

The id guard is the seatbelt under the seatbelt

AbortController is standard but not universal (some wrappers, some proxies, some test environments). The monotonically increasing request id costs nothing and discards any response that is no longer newest, making correctness independent of transport capabilities.