Why does my UI state sometimes revert after async dedupe runs?

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?

Ellen

You’ve got two requests racing to write the same state. The last one to finish wins, even if it belongs to the old filter set.

Use a version token and make stale responses no-op.

let state = { itemsById: new Map(), order: [], cursor: null };
let stateVersion = 0;

async function loadNext(cursor) {
  const v = stateVersion;

  const res = await fetch(`/api/items?cursor=${cursor}`);
  const { items, nextCursor } = await res.json();

  if (v !== stateVersion) return;

  const nextItemsById = new Map(state.itemsById);
  for (const it of items) nextItemsById.set(it.id, it);

  state = {
    itemsById: nextItemsById,
    order: Array.from(new Set([...state.order, ...items.map(i => i.id)])),
    cursor: nextCursor,
  };
}

function onFilterChange() {
  stateVersion++;
  state = { itemsById: new Map(), order: [], cursor: null };
  loadNext(null);
}

function onScrollNearBottom() {
  loadNext(state.cursor);
}

The important part is bumping stateVersion every time you reset the list. That makes older fetches harmless when they finally come back.

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.