WebMriya
Back to articles

React Autocomplete Without Stale Results

A robust React autocomplete needs more than debounce: combine delayed requests, cancellation, stale-response guards, and deliberate loading and error states.

React
TypeScript
Frontend Architecture
W
Frontend Architecture & Legacy Modernization
October 15, 2026 · 5 min read

A user types “ca” into a product search, then quickly adds “mera.” The interface shows results for “ca” after the newer results have already appeared—or replaces them a moment later. To the user, search feels unreliable. In the network panel, both requests look successful. The problem is that responses can arrive in a different order from the requests that started them.

Debounce reduces traffic; it does not guarantee correct results

Debouncing waits until typing pauses before starting a request. That avoids sending one request per keystroke, but it does not prevent an earlier request from finishing last. If “ca” takes 500 ms and “camera” takes 120 ms, the older response can overwrite the newer one. The same race can happen when users clear the field, switch filters, or navigate away while a request is in flight.

A reliable autocomplete should handle three separate concerns: when a request starts, whether obsolete work can be stopped, and whether a response is still relevant when it arrives. Treating these as one “debounce” problem tends to leave gaps.

Give each query a clear lifecycle

A practical lifecycle is straightforward: wait briefly after input changes, cancel the previous timer and request when the query changes, and apply a response only if it still belongs to the latest query. The timer and the request need separate cleanup because a previous query might still be waiting to start when the next one arrives.

tsx
useEffect(() => {
  const normalized = query.trim();
  if (!normalized) {
    setResults([]);
    setLoading(false);
    return;
  }

  const controller = new AbortController();
  const requestId = ++latestRequest.current;
  const timer = window.setTimeout(async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(normalized)}`, { signal: controller.signal });
      if (!response.ok) throw new Error(`Search failed: ${response.status}`);
      const data: SearchResponse = await response.json();
      if (requestId === latestRequest.current) setResults(data.items);
    } catch (error) {
      if (!controller.signal.aborted && requestId === latestRequest.current) setError(error);
    } finally {
      if (requestId === latestRequest.current) setLoading(false);
    }
  }, 250);

  return () => {
    window.clearTimeout(timer);
    controller.abort();
  };
}, [query]);

This effect assumes `latestRequest` is a ref, `SearchResponse` describes the API payload, and the component already owns `query`, `results`, `loading`, and `error` state. The 250 ms delay is an example, not a universal setting: tune it against the product’s interaction speed and API latency. Normalize input before requesting it, but keep the displayed query separate if the product needs to preserve exactly what the user typed.

Cancellation and stale-response protection solve different problems

`AbortController` asks the browser to stop the old fetch and its response-body work. This can save client-side work and reduce unnecessary processing, but it does not guarantee that a server has stopped handling a request it already received. Nor should correctness depend on cancellation winning a race against a response that is already resolving.

The request ID provides the correctness check: only the latest request may update results, errors, or loading state. That matters because stale work can affect more than the list itself. For example, an old request’s `finally` block could otherwise turn off a spinner while the newest request is still pending. The cleanup’s abort check also prevents an expected cancellation from being presented as a user-facing failure.

Define what the user sees between requests

The data lifecycle is only half of the interaction. Decide whether to keep old results visible while searching, clear them immediately, or show a lightweight loading indicator. Each choice communicates a different promise. Keeping previous results can make the interface feel stable, but label or visually distinguish them if users might mistake them for matches to the new query.

  • Empty input: clear results and return to the default state.
  • New query pending: show a clear loading state; do not imply that old results match.
  • Successful response with no matches: show an explicit empty state, not a blank panel.
  • Request failure: show a recoverable error and keep retry behavior clear.
  • Obsolete request aborted: stay quiet; cancellation is normal during typing.

Also consider a minimum query length and an explicit submit path. These are product rules, not implementation tricks: a one-character query may be too broad for a large catalog, while a keyboard or assistive-technology user still needs to understand and operate the suggestions. If selection is supported, keep keyboard focus, the active suggestion, and the committed value distinct.

Keep request logic out of the rendering layer

As search grows to include permissions, filters, caching, or multiple endpoints, placing all request behavior inside the component makes it harder to test and reuse. Isolate the API client and keep the component responsible for interaction and presentation. The same separation is useful beyond autocomplete; see Keep API Clients Out of React Components. If results are shared across screens or mutations change the underlying data, define how cached results become fresh rather than adding ad hoc fetches; React Query Cache Invalidation: Stop Refetching Everything covers that related concern.

Test the ordering, not just the happy path

A test that types a query and receives one response will not expose the race. Make two requests, resolve the newer one first, then resolve the older one. Assert that the newer results remain visible. Also verify that clearing the field cancels a pending timer, an aborted request does not show an error, and an older request cannot clear the loading state for a newer one. These tests protect the behavior users notice, not a particular hook implementation.

For a small field with no shared data, a local effect can be enough. For search that drives a complex application, use a query layer when caching, deduplication, retries, or coordination across screens are real requirements. Avoid introducing that machinery solely to postpone a request by a few milliseconds.

Build the interaction around explicit guarantees

A dependable autocomplete is not created by debounce alone. Debounce controls request timing; cancellation limits obsolete client work; a latest-request check prevents stale updates; and intentional UI states make the behavior legible. Together, these decisions cover both the network race and the user’s experience of it.

When autocomplete is part of a broader search flow, account, or data-heavy product, the same details connect to wider application architecture. WebMriya builds complex web applications and product interfaces where asynchronous interactions need to remain predictable as features grow.

Frequently asked questions

Does debounce prevent stale results?

No. Debounce delays when a request starts, but responses can still arrive out of order. Use cancellation and a latest-request check to prevent obsolete responses from updating the interface.

Is AbortController enough to handle autocomplete requests?

It helps stop client-side fetch and response-body work, but should not be the only correctness mechanism. A request ID or equivalent guard ensures only the latest response updates the UI.

How long should an autocomplete debounce delay be?

There is no universal value. Choose a delay that balances responsiveness against unnecessary requests, then check it against real typing behavior and your API’s latency.