DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your useEffect Search Bar Has a Race Condition, and It Only Shows Up When Users Type Fast

This is one of those bugs where the code looks completely reasonable, passes every manual test you'd naturally think to run, and still ships with a real defect that only shows up under a specific, very common usage pattern: someone typing faster than your network requests resolve.

The Setup

// components/SearchBox.tsx
'use client';

export function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Result[]>([]);

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

    async function fetchResults() {
      const res = await fetch(`/api/search?q=${query}`);
      const data = await res.json();
      setResults(data.results);
    }

    fetchResults();
  }, [query]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.title}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Type slowly, pause between letters, and this works exactly as expected. It's also wrong in a way that's specifically invisible to that kind of careful, deliberate testing.

What Actually Breaks

Say a user types "react" quickly, letter by letter: r, re, rea, reac, react. Each keystroke triggers the effect, which fires a new fetch request for that exact partial query. These five requests are now all in flight simultaneously, and nothing about this code guarantees they resolve in the order they were sent.

Network conditions are not guaranteed to preserve request order. It's entirely possible, and in practice fairly common under real-world network jitter, for the request for "r" to resolve after the request for "react" does. When that happens, setResults(data.results) runs last for the "r" response, overwriting the correct, complete results for "react" with a handful of broad, barely-relevant matches for a single letter the user stopped looking at four keystrokes ago.

The user sees a results list that doesn't match what's sitting in the input field, with no error, no warning, nothing to indicate anything went wrong. It just looks like the search is broken or laggy, which is a uniquely frustrating kind of bug to report because it's intermittent and timing-dependent, not consistently reproducible by just repeating the same steps slowly.

Why This Specifically Survives Testing

Manual testing during development almost always involves typing at a normal, deliberate pace, often pausing to look at the screen between each change. That pace gives each request time to resolve well before the next one fires, which means the race condition's actual precondition, overlapping in-flight requests for different queries, simply never occurs during the kind of testing most developers naturally do. The bug needs genuinely fast typing, a slow or inconsistent network, or both, to reliably surface, and automated tests using scripted input often type unrealistically fast without that being the specific thing anyone's checking for.

The Fix: Ignore Stale Responses

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

  let ignore = false;

  async function fetchResults() {
    const res = await fetch(`/api/search?q=${query}`);
    const data = await res.json();

    if (!ignore) {
      setResults(data.results);
    }
  }

  fetchResults();

  return () => {
    ignore = true;
  };
}, [query]);
Enter fullscreen mode Exit fullscreen mode

The ignore flag is set in the effect's cleanup function, which React runs right before the effect re-runs for a new query value, or when the component unmounts. Every in-flight request from a now-outdated query gets its ignore flag flipped to true the moment a newer request starts, so even if the old request resolves later, its result is silently discarded instead of overwriting the current, correct state.

A Cleaner Version With AbortController

The ignore-flag pattern works, but it still lets the outdated network request complete in the background, wasting bandwidth and a server round trip for a result nobody will ever see. AbortController actually cancels the request itself:

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

  const controller = new AbortController();

  async function fetchResults() {
    try {
      const res = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      setResults(data.results);
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        console.error('Search failed:', err);
      }
    }
  }

  fetchResults();

  return () => {
    controller.abort();
  };
}, [query]);
Enter fullscreen mode Exit fullscreen mode

Now a stale request is actually cancelled, not just ignored, which both fixes the race condition and avoids burning server resources on searches the user has already moved past.

Where Else This Exact Pattern Bites

Any useEffect that fetches data based on a value that can change again before the fetch resolves has this same underlying issue, not just search boxes. A filter dropdown firing a new query on every change, a tab switcher loading different data per tab, pagination where a user clicks "next" rapidly, all of these share the identical race condition shape. It's the same category of timing bug I build defensively around in the dashboard and data-table components across my Next.js templates, same root cause, different trigger.

The Rule

Any effect that fetches data based on a value that can change again before the request resolves needs a way to discard or cancel the outdated response. Either an ignore flag or an AbortController, but not neither. The bug is invisible in slow, deliberate testing and reliably present the moment a real user interacts with your app faster than your network responds.

Get the templates: https://pixelanas.gumroad.com

Go check your own search boxes, filters, and tab switchers for a useEffect fetch with no cleanup-based cancellation. If you find one, try typing into it as fast as you actually can. Drop what you find in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)