Your React Search Results Can Arrive In The Wrong Order
Type “camera”, pause, then change it to “cable”. The cable results arrive first. A moment later, the slower camera request finishes and replaces them. The input says one thing; the list shows another.
A debounce reduces how often you send requests, but two requests can still overlap. Old requests should not win your search. The hook below cancels work as soon as the input changes and checks a request number before accepting a result.
The Endpoint This Example Expects
This is a client-side product search, with a 300 ms pause and a minimum of two trimmed characters. Those are choices for this example, not React requirements. It starts searching from the input handler; it does not fetch on mount.
You need your own GET /api/products?q=... endpoint on the same origin. That path is an integration point, not an API supplied with this post. It should return JSON in this shape, with unique string IDs:
{
"items": [
{ "id": "product-1", "name": "Example product" }
]
}
Return { "items": [] } when nothing matches. If your API returns numeric IDs or a different field name, adapt the validation and mapping below. Authorization, rate limits and result limits belong on the server.
The Hook: Cancel First, Then Wait
Save this as useProductSearch.js in your React application. Each edit clears the pending timer, aborts the previous controller and advances a version number. It also clears the displayed results immediately, so the old list does not sit under a new search term.
'use client';
import { useEffect, useRef, useState } from 'react';
function cancel(work) {
work.version += 1;
clearTimeout(work.timer);
work.controller?.abort();
work.timer = null;
work.controller = null;
}
export function useProductSearch() {
const workRef = useRef({ version: 0, timer: null, controller: null });
const [state, setState] = useState({
query: '', status: 'idle', items: [], error: '',
});
useEffect(() => {
const work = workRef.current;
return () => cancel(work);
}, []);
function search(query) {
const work = workRef.current;
cancel(work);
const version = work.version;
const term = query.trim();
const ready = term.length >= 2;
setState({
query, status: ready ? 'waiting' : 'idle', items: [], error: '',
});
if (!ready) return;
const controller = new AbortController();
work.controller = controller;
work.timer = setTimeout(async () => {
if (work.version !== version) return;
setState({ query, status: 'loading', items: [], error: '' });
try {
const params = new URLSearchParams({ q: term });
const response = await fetch(`/api/products?${params}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Search failed (HTTP ${response.status}).`);
}
const data = await response.json();
if (!Array.isArray(data?.items) || !data.items.every(item =>
item !== null && typeof item === 'object' &&
typeof item.id === 'string' && typeof item.name === 'string'
) || new Set(data.items.map(item => item.id)).size !== data.items.length) {
throw new Error('Search returned an unexpected response.');
}
if (work.version !== version) return;
setState({ query, status: 'success', items: data.items, error: '' });
} catch (error) {
if (work.version !== version || controller.signal.aborted) return;
setState({
query, status: 'error', items: [],
error: error instanceof Error ? error.message : 'Search failed.',
});
}
}, 300);
}
return { ...state, search };
}
The timer is reset on each call to search(). Once the user pauses for 300 ms, the request starts. URLSearchParams encodes the term, so a search containing an ampersand does not accidentally become another query parameter.
Why Keep The Version Number?
Aborting a fetch can stop the browser waiting for a response or reading its body. It does not roll back work already received by the server. See MDN's fetch cancellation documentation.
The version check answers a separate question: does this completion still belong to the current search? Both the success and error paths check it. Otherwise, an old failure could replace a newer success with an error message.
Comparing search text alone is not enough. Someone can type “camera”, switch to “cable”, then return to “camera”. The first and third requests have the same text but different version numbers. Only the third can update the screen.
The timer, controller and version live in a ref because changing them does not need a render. The visible query, results and status live in state. React's useRef documentation explains that distinction. Here, the ref is read and changed in handlers and cleanup, not during rendering.
Wire It To An Input
Put ProductSearch.jsx beside the hook and render <ProductSearch /> from your app. Both files include 'use client' for projects with a server/client component boundary. This is JavaScript with JSX, so use your application's existing React build setup.
'use client';
import { useId } from 'react';
import { useProductSearch } from './useProductSearch.js';
export default function ProductSearch() {
const id = useId();
const { query, status, items, error, search } = useProductSearch();
const message = status === 'idle' ? 'Type at least two characters.'
: status === 'waiting' ? 'Waiting for you to finish typing...'
: status === 'loading' ? 'Searching...'
: status === 'success' ? `${items.length} results found.`
: '';
return (
<section aria-label="Product search">
<label htmlFor={id}>Search products</label>
<input
id={id}
type="search"
value={query}
onChange={event => search(event.target.value)}
aria-describedby={`${id}-status`}
/>
<p id={`${id}-status`} role="status">{message}</p>
{status === 'error' && (
<div>
<p role="alert">{error}</p>
<button type="button" onClick={() => search(query)}>Try again</button>
</div>
)}
{status === 'success' && (
items.length > 0
? <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
: <p>No products matched your search.</p>
)}
</section>
);
}
The status text stays in the page for screen readers to announce. An empty response gets a different message from a failed request. The retry button calls the same handler with the current input, creating a new request even when the text has not changed.
HTTP errors need an explicit response.ok check: fetch() does not reject merely because a response is a 404 or 500. That behavior is documented in MDN's response status guide. The hook also rejects malformed data before the component tries to render it.
Cleanup Matters After You Leave The Page
The effect exists only to cancel pending work when the component unmounts. It does not start a request. Cleanup also advances the version, so even a late completion that ignores cancellation cannot publish a result.
React runs an extra setup and cleanup cycle for effects in development Strict Mode. This hook does not use a “run once” flag to suppress it. The next input event creates fresh work. React describes the cleanup cycle in the useEffect reference.
What Was Tested
The hook passed 16 automated checks with React and React DOM 19.3.0, Node 26.5.0 and jsdom. These used real timers and mocked fetch responses, not a live product API. The tests deliberately allowed an aborted request to finish, so passing did not depend on cancellation alone.
- Rapid edits produce one request after the pause; short and blank input produce none.
- Older responses and errors cannot overwrite newer results, including when a previous search term is entered again.
- Clearing the input cancels scheduled work and ignores in-flight results.
- HTTP failures, network failures, malformed JSON, invalid items and duplicate IDs produce an error state; retry can recover.
- Empty results, Strict Mode mounting and unmount cleanup follow the expected state transitions.
The JSX example was also compiled and server-rendered to check its initial markup. That is not a browser accessibility audit or a test of your backend. Check it with your own endpoint, network conditions and app before shipping.
Where To Stop Adding Features
This hook has no shared cache, pagination, automatic retries or request timeout. A request can remain pending until it finishes, fails, or is cancelled by another edit or unmount. It suits a small search interaction whose requests start from user input.
If your app already uses a query library or framework data layer, keep using it for fetching and caching. React's data-fetching guidance explains why those facilities matter as an application grows.
And useDeferredValue is not a replacement for the timer here. It can defer rendering, but it does not itself reduce network requests. React calls that out in the useDeferredValue documentation.