A new teaching example based on Web API documentation for practicing search-state management; no claim is made that existing business projects use this implementation.
01 / Why debounce alone is insufficient
A user searches for A and then AB. If A takes longer, its response can arrive after AB and overwrite the view. Debounce reduces request starts; it cannot order completion of requests already sent. A successful response may still belong to outdated input.
Cancellation reduces unnecessary work; a sequence ID decides which result may update the view. The server may already have received the request, and some caches or mock adapters may ignore cancellation.
02 / Name the view states
This example uses idle, loading, success, empty and error. A new query clears old results; a failed load should offer retry, while intentional cancellation stays silent. A product that retains previous results should label them as refreshing and disable unsafe actions that depend on stale data.
Mark the results region with aria-busy and announce completion or failure through aria-live="polite". Keep focus in the search input rather than moving it on every update. Do not encode state with color alone.
03 / A controller that can be checked independently
Save the code as .mjs. Supply request(query, signal) and render(state) to createLatestRequest. The request adapter should return an array and validate HTTP status and response shape. Call search on input changes and dispose when the component unmounts.
Use onUnmounted in Vue or effect cleanup in React. Debounce timers belong to the caller and also need cleanup. Keep the controller stable across renders so its sequence remains meaningful.
// Small controller for a search interface; inject rendering at the call site.
// request must resolve to data, for example: fetch(url, {signal}).then(r => r.json()).
export function createLatestRequest(request, render) {
let sequence = 0;
let controller;
return {
async search(rawQuery) {
const id = ++sequence;
controller?.abort();
const query = rawQuery.trim();
if (!query) { render({ status: "idle", items: [] }); return; }
controller = new AbortController();
const signal = controller.signal;
render({ status: "loading", items: [] });
try {
const items = await request(query, signal);
if (id !== sequence) return;
render({ status: items.length ? "success" : "empty", items });
} catch (error) {
if (id !== sequence || signal.aborted) return;
render({ status: "error", items: [], message: String(error.message) });
}
},
dispose() { ++sequence; controller?.abort(); },
};
}
04 / Control completion order to test boundaries
Avoid fixed sleeps in race tests. Create two manually resolvable promises: start A then AB, resolve AB before A, and require the final view to still show AB. Also check stale rejection, cleared input, an empty array, current failure and a late result after disposal.
05 / View correctness does not imply one server-side action
This example is for read-only queries. AbortController cannot guarantee cancellation or idempotency of orders, stock changes or uploads. Those need server-side request identifiers, state transitions and transaction boundaries. Preventing stale UI updates and preventing duplicate business actions are separate checks.
Sources & further reading
Compiled from code and documents, with AI-assisted drafting and translation. Examples and proposed checks are learning material; only linked evidence establishes what was run.