Writing a Debounced Search Hook in React That Actually Handles Race Conditions

By James Nguyen Updated September 24, 2026
Writing a Debounced Search Hook in React That Actually Handles Race Conditions

A search box on a project I worked on kept flashing stale results, type "cat," see cat results, keep typing to "catalog," and for a split second see the old "cat" results again before the "catalog" results replaced them. The debounce delay itself was fine; the actual bug was a request race condition that debouncing alone doesn't fix, and building the hook that actually handles it properly is more subtle than the copy-pasted versions floating around.

Debouncing the Value, Not the Fetch Call Directly

The cleanest separation I've settled on is a generic useDebounce hook that debounces any value, and a separate effect that reacts to the debounced value changing. This keeps the debounce logic reusable for anything, not just search, filters, form autosave, a resize handler, and keeps the data-fetching logic in the component that actually needs it rather than baked into the debounce hook itself.

import { useState, useEffect } from 'react';

function useDebounce(value, delayMs = 400) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    // Cancel the pending timeout if value changes again before it fires
    return () => clearTimeout(id);
  }, [value, delayMs]);

  return debounced;
}

Why Debouncing Alone Doesn't Prevent Stale Results

Debouncing controls when a request fires, not the order in which responses arrive. If a fast network response for an earlier query lands after a slower response for a later query, whichever one resolves last wins and overwrites the state, regardless of which one was actually the most recent search. This is exactly what caused the flash I saw: "cat" and "catalog" both fired (400ms apart, both past the debounce delay by the time I'd kept typing), and the "cat" response, for whatever network reason, resolved after "catalog"'s.

AbortController Fixes the Race, Not Just the Wasted Request

The common advice is "use AbortController to cancel outdated requests," which is correct but the reasoning usually stops at "saves bandwidth." The more important effect is that a properly aborted request's promise rejects with an AbortError instead of resolving, so it never reaches the .then() that would otherwise overwrite state with stale data. Cancelling isn't just cleanup, it's the actual mechanism that prevents the late-arriving stale response from winning the race.

import { useState, useEffect } from 'react';

function useSearchResults(query) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => {
        if (!res.ok) throw new Error(`search failed: ${res.status}`);
        return res.json();
      })
      .then((data) => setResults(data.results))
      .catch((err) => {
        // AbortError fires when we cancel this ourselves — not a real error
        if (err.name !== 'AbortError') setError(err.message);
      })
      .finally(() => setLoading(false));

    // Runs before the next effect, or on unmount — cancels this in-flight request
    return () => controller.abort();
  }, [query]);

  return { results, loading, error };
}

Composing the Two Hooks Together

The component itself stays simple: raw input state updates on every keystroke for a responsive text field, that value gets debounced, and the debounced value drives the fetch. Because the abort happens in useSearchResults's own cleanup, every value change, not just the debounced ones, correctly cancels whatever request was in flight before the new one starts.

function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);
  const { results, loading, error } = useSearchResults(debouncedQuery);

  return (
    
setQuery(e.target.value)} placeholder="Search..." aria-busy={loading} /> {error &&

{error}

}
    {results.map((r) =>
  • {r.title}
  • )}
); }

The finally() Ordering Bug I Hit Testing This

Putting setLoading(false) in .finally() looked correct until I noticed the loading spinner briefly flickering off and back on between an aborted request and its replacement. The abort's catch branch and the next request's setLoading(true) can interleave in a way that's visually noisy even though the data itself is correct. Guarding the .finally() to skip setLoading(false) specifically when the error was an AbortError fixed the flicker, since an aborted request is immediately superseded by a new one that's already set loading back to true anyway.

Handling the Empty-Query Case Explicitly

Clearing the input should clear results immediately, not after the debounce delay elapses, since waiting there just makes the UI feel unresponsive for no benefit; there's no request to actually debounce when there's nothing to search for. The explicit early return for an empty, trimmed query at the top of the effect handles this without it needing a separate code path outside the hook.

Testing the Race Condition, Not Just the Happy Path

A test that only checks "typing X eventually shows X's results" won't catch this bug, since it doesn't simulate out-of-order response timing. Mocking fetch to resolve the first call after the second call, deliberately inverting the natural order, and asserting the final rendered results match the second (later) query rather than the first is what actually exercises the abort logic and would have caught the original flashing-results bug before it shipped.

Final Verdict

A debounced search hook that only debounces the trigger is half the solution; the AbortController-driven cancellation in the fetch effect is what actually guarantees the UI reflects the latest query regardless of network timing. It's a small amount of extra code over the naive version, but it's the difference between a search box that occasionally shows the wrong results under normal typing speed and one that doesn't.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles