Cause
Race condition: when you fire off a new fetch for each keystroke, slower older requests can resolve after newer ones, overwriting fresh results with stale data (since promises don't resolve in order).
Solution: Cancel stale requests with AbortController
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) return;
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort(); // cancel previous request
}, [query]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
How it works: Every time query changes, the effect cleanup aborts the in-flight request from the previous keystroke before starting a new one. Aborted requests throw an AbortError, which you ignore—ensuring only the latest request's results ever update state.