Crux Read real React/TanStack Query snippets over an async backend, predict the UX bug, and pick the highest-leverage fix a senior would make first.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
Eventual-consistency bugs hide in the mutation handler and the retry path. Read the code, predict what the user sees when the consumer lags or the request fails, then choose the fix a senior engineer would make first.
Goal
Practise the loop you run in every async-UX review: read the mutation, predict the divergence between optimistic state and server truth, and reach for the contract-completing fix — snapshot, rollback, reconcile, key — before adding spinners.
The request occasionally fails (the consumer rejects the write). What does the user see, and what is the single highest-leverage fix?
Heads-up invalidateQueries refetches server state, but until that refetch resolves the stale optimistic value is shown, and if the refetch itself races the consumer it can re-confirm the wrong state. Rollback on error must be explicit.
Heads-up React does not know the mutation failed or what the prior value was. Without a snapshot and an onError restore, the optimistic entry persists.
Heads-up Awaiting cancelQueries is correct: it stops in-flight refetches from clobbering the optimistic value. The defect is the missing snapshot and rollback.
Snippet 2 — the retry button
function PayButton({ orderId, amount }) { const [status, setStatus] = useState('idle'); async function pay() { setStatus('pending'); await fetch('/api/charge', { method: 'POST', body: JSON.stringify({ orderId, amount }), }); setStatus('done'); } // user sees no progress, clicks pay() again on a slow network return <button onClick={pay}>Pay</button>;}
Quiz
Completed
On a slow network the user clicks twice. What goes wrong, and what is the right fix?
Heads-up Hiding the button is a race, not a guarantee — a refresh or a second tab still re-triggers it. At-most-once requires an idempotency key on the request, not faster UI.
Heads-up Both requests succeed independently; there is no error to catch. The problem is two real, accepted intents — only a shared key collapses them to one effect.
Heads-up HTTP method semantics do not make a charge idempotent on their own — the server still creates a new charge per request unless it deduplicates on an explicit idempotency key.
Snippet 3 — the fake-success toast
async function publish(post) { const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(post), }); if (res.status === 202) { toast.success('Published!'); await qc.invalidateQueries({ queryKey: ['posts'] }); }}
Quiz
Completed
The POST returns 202 and a consumer writes the post ~800ms later. What two things are wrong here?
Heads-up 202 Accepted means 'queued', not 'done'. Announcing success and refetching inside the consistency window are exactly the two failure modes the unit warns about.
Heads-up toast.success is fire-and-forget UI; awaiting it changes nothing. The real bugs are treating 202 as done and refetching inside the window.
Heads-up There is no 200 coming — the endpoint is async by design and returns 202. Retrying just enqueues duplicate work; you need a pending state and reconciliation.
Snippet 4 — the conflict that overwrites
function onIncomingUpdate(remote) { // local has unsaved edits; remote arrived from another client setDoc(remote); // last write wins, silently}
Quiz
Completed
Two clients edit the same document body. This handler runs on the incoming remote update. What is the senior objection, and the better shape?
Heads-up Newest-wins on free-form text discards real work and trusts clocks that can skew. For a document body this is data loss; a CRDT merges both edits deterministically instead.
Heads-up Memoisation is irrelevant to correctness here. The defect is silently overwriting the user's edits rather than reconciling or surfacing the conflict.
Heads-up A timing hack does not resolve a conflict — it just changes which write is lost. You need a real merge strategy (CRDT) or an explicit conflict choice.
Recap
Every async-UX bug is read in the mutation and the retry path: an optimistic update without a snapshot-and-onError rollback strands a failed write on screen; a POST without an idempotency key turns a double-click into a double charge; collapsing 202 into a success toast plus an immediate refetch is fake success racing the consumer; and a silent last-write-wins handler destroys a user’s unsaved edits. Complete the apply-send-reconcile contract, key the retries, show honest pending states, and reconcile conflicts deliberately instead of overwriting.