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.