The problem
A developer built a search bar in React. Each time the user types a letter, the component sends a request to the server and shows the results. Most of the time it works. But sometimes the list shows results for an older, shorter search. The box says "react", and the list shows results for "r". Typing slowly hides the bug. Typing fast makes it show up again.
This is a common bug, and it is easy to miss in local testing, because a local server answers almost every request at the same speed.
The root cause
Every keystroke starts a new request. The network does not promise that answers come back in the same order the requests went out. A short query like "r" can match many rows and take longer on the server. A longer query like "react" can come back fast.
So the timeline can look like this:
0 ms start request for "r" (slow, 300 ms)
40 ms start request for "re" (fast, 20 ms)
60 ms "re" answers -> list shows "re" results (correct)
300 ms "r" answers -> list shows "r" results (wrong, but it was the last setState)
The buggy code calls setState for every answer, so the last answer to arrive wins, not the last question asked:
// Buggy: every response updates state, in arrival order
useEffect(() => {
if (!query) return;
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => res.json())
.then((data) => setResults({ query, items: data.items }));
}, [query]);
The React docs describe this exact case in "You Might Not Need an Effect": the responses for "hell" and "hello" can arrive in either order, and whichever calls setResults() last is what the user sees. They call it a race condition.
The fix
The fix has three parts. Each part solves a different piece, and all three live in the effect's cleanup function, which React runs before the effect runs again and when the component unmounts.
-
An
ignoreflag. Each effect run gets its own flag. Cleanup sets it totrue, so a late answer from an old run can no longer change state. This is the part that makes the result correct. -
An
AbortController. Cleanup callscontroller.abort(). Per MDN, this can abort a fetch request and the reading of its response body. The aborted fetch rejects with anAbortError, which we treat as normal, not as a failure. This part saves network and server work. - A short debounce. A timer waits until the user pauses before it sends a request. Cleanup clears the timer, so fast typing sends one request instead of five. This part cuts load. It does not fix ordering by itself.
Here is the full hook:
import { useEffect, useState } from 'react';
export function useSearch(query, { fetchImpl = fetch, delayMs = 250 } = {}) {
const [state, setState] = useState({ status: 'idle', query: '', items: [], error: null });
useEffect(() => {
const q = query.trim();
if (!q) {
setState({ status: 'idle', query: '', items: [], error: null });
return;
}
const controller = new AbortController();
let ignore = false;
const timer = setTimeout(async () => {
setState((s) => ({ ...s, status: 'loading', error: null }));
try {
const res = await fetchImpl(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!ignore) setState({ status: 'done', query: q, items: data.items, error: null });
} catch (err) {
if (ignore || err.name === 'AbortError') return; // expected, not a real failure
setState((s) => ({ ...s, status: 'error', error: err }));
}
}, delayMs);
return () => {
ignore = true; // late answers from this run can no longer touch state
clearTimeout(timer); // a request that has not started yet never starts
controller.abort(); // a request in flight is cancelled
};
}, [query, fetchImpl, delayMs]);
return state;
}
Use it like this:
function SearchBox() {
const [query, setQuery] = useState('');
const { status, query: shownFor, items, error } = useSearch(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{status === 'loading' && <p>Loading…</p>}
{status === 'error' && <p>Something went wrong: {error.message}</p>}
<p>Showing results for: {shownFor}</p>
<ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>
</>
);
}
Two small details matter:
- Keep the
querythat produced the results in state next to the items. Then the UI can show which search the list belongs to, and a test can check it. - Do not pass a new
fetchImplfunction on every render, or the effect will re-run each time. Either leave the default, or create the function once outside the component.
Why keep the ignore flag if we already abort?
Abort is best effort. If the response is already done by the time cleanup runs, or if the fetch function you call does not pass the signal along (some wrappers drop it), the old promise can still resolve. The ignore check is a plain, in-memory test, so it works in every one of those cases. The React docs show the ignore flag as the basic fix and say cleanup should "either abort the fetch or ignore its result." We do both.
How we tested it
We ran this on Node 20 with React 19, @testing-library/react, and jsdom. A fake fetch makes one-letter queries slow (300 ms) and longer queries fast (20 ms), and it honors AbortSignal like the real one. Four tests:
test('buggy hook shows results for an old query', async () => {
const log = []; const f = makeFakeFetch(log);
const { rerender } = render(h(BuggyBox, { query: 'r', fetchImpl: f }));
rerender(h(BuggyBox, { query: 're', fetchImpl: f }));
await act(() => sleep(400));
assert.equal(screen.getByTestId('out').textContent, 're -> r'); // box says "re", list is for "r"
});
test('fixed hook keeps the newest query and aborts the old one', async () => {
const log = []; const f = makeFakeFetch(log);
const { rerender } = render(h(FixedBox, { query: 'r', fetchImpl: f, delayMs: 0 }));
await act(() => sleep(5));
rerender(h(FixedBox, { query: 're', fetchImpl: f, delayMs: 0 }));
await act(() => sleep(400));
assert.equal(screen.getByTestId('out').textContent, 're -> re');
assert.ok(log.includes('abort r'));
});
test('debounce: fast typing sends one request', async () => {
// types r, re, rea, reac, react 30 ms apart with a 100 ms debounce
assert.deepEqual(log, ['start react', 'finish react']);
});
test('ignore flag still protects when the old request cannot be aborted', async () => {
// fetch wrapper drops the signal on purpose
assert.ok(log.includes('finish r')); // old answer did arrive
assert.equal(screen.getByTestId('out').textContent, 're -> re'); // but it was ignored
});
Result: 4 of 4 tests pass. The request logs from the run:
fixed log: start r, abort r, start re, finish re
debounce log: start react, finish react
The first test proves the bug is real. The other three prove each part of the fix does its own job.
Trade-offs
- Debounce adds delay. Every search waits for the pause before it starts. A longer delay sends fewer requests but feels slower. A shorter delay feels faster but sends more. Pick a value and measure it with real users.
- Abort does not undo work. The server may have already run the query. Abort saves bandwidth and frees the browser connection. It does not roll back anything on the server, so never rely on abort for requests that change data.
- Two requests in development are normal. With Strict Mode, React runs the effect, cleans it up, and runs it again in development. The docs say you will see two fetches in the Network tab, and the cleanup makes the first one harmless. In a production build there is one.
- A data library may fit better. The React docs point out that caching, server-side fetching, and request waterfalls are still open problems with hand-written effects, and that frameworks and data libraries handle them. If your app already uses one, use its query hooks instead of this hook. If it does not, this hook is a small, clear step, and it is easy to replace later.
-
The
ignoreflag alone is enough for correctness. If you cannot abort (for example, an SDK that does not accept a signal), the flag still keeps the screen right. You lose only the network savings.
Make sure the stale results are gone
- Run the tests. The buggy test should pass (it proves the bug), and the three fixed tests should pass.
- Slow the network in the browser. Open DevTools, go to the Network tab, and pick a slow throttling preset. Type a word quickly. You should see older requests marked as cancelled, and only the last one should complete.
- Show the source query. Render "Showing results for: …" next to the list, as in the example. Type fast, then compare it with the input. They should always match once loading ends.
-
Check the console. There should be no errors for aborted requests. If you see
AbortErrorin your error UI, thecatchblock is not filtering it.
Official sources
- React docs, You Might Not Need an Effect (section "Fetching data", race conditions and the
ignoreflag): https://react.dev/learn/you-might-not-need-an-effect - React docs, Synchronizing with Effects (section "Fetching data", abort or ignore in cleanup, two fetches in development): https://react.dev/learn/synchronizing-with-effects
- React docs, useEffect reference (section "Fetching data with Effects"): https://react.dev/reference/react/useEffect
- MDN, AbortController: abort() method: https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
- MDN, AbortSignal: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
Written by the team at ByteBridge Talent LLC, a tech talent and IT staffing firm in Grand Island, Nebraska. Learn more at bytebridgetalent.com.
Top comments (0)