After "cancel" the list never refreshes — or after invalidate a ghost write returns
Cancel stops in-flight work and invalidate marks stale and refetches, and an optimistic mutation needs both in the right order
What you'll build: muscle memory for two Query verbs — cancelQueries stops in-flight work; invalidateQueries marks stale and refetches — used together in the optimistic mutation contract so you neither freeze on cancelled data nor lose an optimistic write to a late refetch.
The scenario
Two bugs from the same team in one week:
A. A "Refresh" button calls queryClient.cancelQueries({ queryKey: ['orders'] }). The spinner stops. The list never updates — support still sees yesterday's orders until a full reload. Dev thought cancel meant "drop cache and reload."
B. An optimistic "Mark shipped" uses setQueryData in onMutate but skips cancelQueries. A background refetch that started before the click resolves after the optimistic write and paints the old row. Flicker: shipped → unshipped → shipped after a manual refresh. They "fixed" it by removing optimism.
Why it escaped QA: cancel "feels" like it did something (in-flight abort); invalidate is tested only on the happy mutation path; the race in B needs an overlapping refetch (focus/remount) during the click — rare in click-scripted tests.
Walkthrough
Stage 1 — Name the two verbs
From Cancel vs invalidate:
cancelQueries | invalidateQueries | |
|---|---|---|
| Intent | Stop / ignore in-flight | Mark stale + refetch active |
| New network? | No | Yes (if observers active) |
| Typical moment | onMutate, Abort button | onSettled after writes |
Cancel without invalidate → no refresh. Invalidate without cancel during optimism → late GET can win the race (mistake 6).
Stage 2 — Reject the confused APIs
- Refresh button = only cancel — aborts; does not refetch. Use
invalidateQueriesorrefetchQueries. - Optimism = only
setQueryData— missing cancel invites the flicker in B. removeQueriesas refresh — drops cache → full pending spinner; heavier than invalidate.
Stage 3 — The real fix (both call sites)
Refresh control:
function RefreshOrders() { const qc = useQueryClient(); return ( <button type="button" onClick={() => qc.invalidateQueries({ queryKey: ["orders"] })}> Refresh </button> );}Optimistic ship (contract from the article walkthrough):
useMutation({ mutationFn: shipOrder, onMutate: async (id) => { await queryClient.cancelQueries({ queryKey: ["orders"] }); const previous = queryClient.getQueryData(["orders"]); queryClient.setQueryData(["orders"], (old: Order[] | undefined) => old?.map((o) => (o.id === id ? { ...o, status: "shipped" } : o)), ); return { previous }; }, onError: (_e, _id, ctx) => { if (ctx?.previous) queryClient.setQueryData(["orders"], ctx.previous); }, onSettled: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),});Stage 4 — Harden + verify the loop
- Prefer
onSettledoveronSuccessfor invalidate (mistake 7). - Manual cancel buttons for downloads still use
cancelQueries— then optionally invalidate if you want a clean idle state.
Verify the loop. A: click Refresh → Network GET /orders, UI updates. B: start a slow refetch (throttle), click Ship mid-flight → row stays shipped (no flicker to old); on error, rolls back then resettles from server.
Variations
refetchQuerieson Refresh — force fetch even if fresh; invalidate only marks stale first.- Partial key cancel —
cancelQueries({ queryKey: ['orders', id] })during row edit. - React
useOptimisticfor local-only toggles — double-submit recipe; cache optimism still needs cancel. - Logout teardown —
cancelQueriesthenclear()(logout-leaves-stale-cache), not invalidate (which would refetch). - Search key change — Query auto-cancels prior key; still wire
signal(search-race).
Trade-offs and common pitfalls
- Cancel ≟ refresh — the core confusion.
- Invalidate on logout — refetches into a dying session; use
clear. - Optimism without cancel — flicker.
- Invalidate only on success — failed mutation skips resync.
- Broad cancel — cancels unrelated in-flight UI; scope keys.
- Ignoring abort in
queryFn— cancel marks Query cancelled but HTTP continues (search-race). setQueryDataafter invalidate without await — races; settle order matters.- Teaching cancel as "clear cache" — it doesn't remove data.
- Double invalidate storms — debounce rapid mutation bursts if needed.
- No verify with throttling — race B never appears on localhost.
When NOT to cancel
Don't cancel a payment mutation mid-flight from a route change — writes aren't auto-aborted for a reason (upload-cant-be-cancelled). Cancel reads freely; cancel writes only with an explicit user "Abort" and server idempotency.
See also
- Cancel vs invalidate
- Stage 5 optimistic walkthrough
- Double-submit and optimistic like
References
- TanStack Query — Query Cancellation
- TanStack Query — Invalidations from Mutations
Demo source
demos/data-fetching/cancel-vs-invalidate-confusion/— refresh button + optimistic race with/without cancel. (Demo host TBD)