Give each request an identity
Keep the current request identity beside the visible query and result state. The following is illustrative JSX, not a browser-tested copy of the fixture; every response must carry the identity that started it:
const nextRequestId = useRef(0);
const currentRequestId = useRef(0);
useEffect(() => {
let ignore = false;
const controller = new AbortController();
const requestId = ++nextRequestId.current;
currentRequestId.current = requestId;
setState({ query, requestId, status: "loading", items: [], error: "" });
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) throw new Error("Search request failed");
return response.json();
})
.then((result) => {
if (!ignore && requestId === currentRequestId.current) {
const items = Array.isArray(result.items) ? result.items : [];
setState({ query, requestId, status: items.length ? "ready" : "empty", items, error: "" });
}
})
.catch((error) => {
if (!ignore && error.name !== "AbortError" && requestId === currentRequestId.current) {
setState({ query, requestId, status: "failed", items: [], error: "Search is unavailable." });
}
});
return () => {
ignore = true;
controller.abort();
};
}, [query]);
The exact state shape can differ. The important properties are that a cleanup can invalidate an older response, a response is checked against the current identity, and an expected abort is not shown as a service failure. Keep the server response contract separate from the browser guard.
Test the order that production will scramble
The evidence packet includes a framework-independent model and a small React
browser fixture. Start request 1 for ca, start request 2 for cat, resolve
request 2 first, then resolve request 1. The visible result must remain
cat. Repeat the exercise with an older abort and an older failure.
Run the deterministic model with:
npm test --prefix sites/reactjsx.com/evidence/P92
The model also checks that an authoritative empty result becomes empty, a
current failure stays visible, and a late result cannot update an unmounted
component. Build the browser fixture with npm run build --prefix sites/reactjsx.com/evidence/P92 before an engineer exercises it in a browser.
The fixture's result panel uses a polite live region and its current error uses
an alert; neither has been tested across the product's supported screen
readers.
Keep empty and failed distinct
An empty result means the current request succeeded and returned no matching items. A failed result means the current request did not produce an authoritative result. They need different copy and different next actions:
| State | Meaning | Useful next action |
|---|---|---|
| Loading | The current request is unresolved | Wait or cancel |
| Ready | The current request returned items | Inspect the matching results |
| Empty | The current request succeeded with no matches | Change the query or filters |
| Failed | The current request is unavailable or rejected | Retry or inspect the service boundary |
Do not clear a useful previous result merely because a refresh failed unless the product has a deliberate stale-data policy. For a search where the query changed, showing the new query's loading state is usually clearer than leaving the old query labeled as current.
Know what the browser guard cannot prove
AbortController can signal a browser request, but the server may already have
received it or may not support cancellation. Neither an abort nor a stale
response guard authorizes a query, deduplicates a mutation, or makes a side
effect safe to repeat. The server still owns authentication, authorization,
rate limits, durable writes, and idempotency.
Stop before shipping if an older response can change current state, an empty result is treated as a failure, a current failure disappears, or an unmounted component can still update. The local fixture is evidence for the state contract; it is not a live API or a browser/assistive-technology matrix.