Hey folks, I’m wiring up a little pixel-art gallery UI and I’m trying to keep client state normalized while multiple async events fire (scroll pagination + quick filter changes). The failure mode is ugly: an older response “wins” and my deduped map overwrites newer state.
let state = { itemsById: new Map(), order: [], cursor: null };
async function loadNext(cursor) {
const res = await fetch(`/api/items?cursor=${cursor}`);
const { items, nextCursor } = await res.json();
// normalize + dedupe
const next = new Map(state.itemsById);
for (const it of items) next.set(it.id, it);
state = {
itemsById: next,
order: Array.from(new Set([...state.order, ...items.map(i => i.id)])),
cursor: nextCursor,
};
}
function onFilterChange() {
state = { itemsById: new Map(), order: [], cursor: null };
loadNext(null);
}
function onScrollNearBottom() {
loadNext(state.cursor);
}
What’s a practical pattern to prevent stale async responses from overwriting newer state here without turning the whole thing into a giant state machine?
Version tokens fix the UI correctness part, but I’d still abort the old fetch on filter change so you’re not burning bandwidth/CPU on a request you already know you’ll throw away. I’ve seen this exact thing where the screen looked fine and meanwhile the backend was happily grinding through “dead” queries and our logs looked… not great.
AbortController works well for this: stash the current controller somewhere, call abort() inside onFilterChange(), then create a new controller for the next loadNext and pass signal into fetch. I’m not 100% sure how your fetch wrapper surfaces aborts, but you usually want to treat an abort as a non-error (don’t toast it, don’t report it), and keep the version check anyway as the seatbelt in case something slips through.
onScrollNearBottom() is kinda a spam cannon — it’ll fire a few times while the first request is still in-flight, so you can accidentally kick off multiple loadNext(cursor) calls racing even when the filter never changed.
What’s saved me here is a boring inFlight guard (or a tiny queue) so pagination is strictly one-at-a-time. Then keep the version token + AbortController for the filter-change case, but don’t rely on those to “fix” noisy scroll triggers—just don’t start page 2 until page 1 finishes.