The failure
A person selects one catalogue filter, then another. Request A starts first but finishes last. A naive controller displays A, although B represents the latest intention. Your code can be syntactically correct and still tell the wrong story.
For the exercise, let the person switch between the normal and slow lab URLs. Replace the previous single-request controller with this factory. It receives your adapter and a state-publishing function, so the concurrency policy can be tested without a browser.
export function createCatalogueLoader(getProducts, publish) {
let generation = 0;
let active;
return {
async load(url) {
const mine = ++generation;
active?.abort();
const controller = new AbortController();
active = controller;
publish({ phase: 'loading', products: [] });
try {
const products = await getProducts(url, { signal: controller.signal });
if (mine !== generation) return;
publish({ phase: products.length ? 'ready' : 'empty', products });
} catch (error) {
if (mine !== generation) return;
if (error.name !== 'AbortError') {
publish({ phase: 'error', products: [], message: 'Catalogue unavailable. Retry.' });
}
}
},
dispose() {
generation += 1;
active?.abort();
}
};
}
publish updates only catalogue state and renders it. dispose is a lifecycle cleanup: call it when leaving this view, not as a visible Cancel button, since it deliberately publishes no replacement state. If you add a user Cancel action, define its resulting state separately.
Two protections, two reasons
Aborting asks the underlying fetch to stop and avoids unnecessary work. The generation check protects the state even if a replacement adapter ignores abort or old processing completes later. Both the success and error paths need that check. An old error must not replace a new success.
There is no unconditional finally that clears loading: request A's finally could hide request B's loading indicator. Only the current generation owns the visible result.
Deterministic evidence
Create two deferred promises in a test. Start A, then B. Resolve B with the notebook and A with the bag. The final published state must contain the notebook. Repeat with A rejecting after B succeeds. A fake adapter that ignores the signal makes this test stronger: you prove the generation rule independently of cancellation.
In the browser, start /api/products?mode=slow, immediately load /api/products?mode=empty, and verify that the empty state stays visible after the slow request would have completed.
Boundary question
Does aborting an order submission mean the server did not process it? No. Cancellation changes what this client waits for; it does not establish the outcome of a remote write. The next lesson separates retrying a read from retrying a commitment.