Optimistic updates
Optimistic updates let you reflect a mutation’s effect in the UI before the server confirms it. useM’s optimistic option makes the snapshot + rollback ceremony declarative.
The lifecycle
Section titled “The lifecycle”For each OptimisticTarget:
cancelQueries— prevent any in-flight refetch of the target from clobbering your optimistic value.getQueryData— snapshot the previous value.setQueryData— apply the optimistic update.- On
onError— restore each snapshot (in reverse order, so layered updates unwind correctly). - On
onSettled—useMruns tag invalidation, which refetches the truth.
You write the updater. Everything else is handled.
Pattern 1: item update
Section titled “Pattern 1: item update”The simplest case: a single mutation updates a single resource.
const updatePost = useM("updatePost", { pathParams: { facilityId, postId }, optimistic: [ { route: "getPost", params: { pathParams: { facilityId, postId } }, updater: (prev, vars) => prev && { ...prev, ...vars.body }, }, ],});
updatePost.mutate({ body: { title: "New title" } });Note updater returns undefined (well, prev && …) when there’s nothing in cache — leaving the cache untouched. This keeps setQueryData from inserting a half-formed entry.
Pattern 2: list update
Section titled “Pattern 2: list update”Most apps render lists and details. After an item update, you want both surfaces to reflect the new value without two roundtrips.
const updatePost = useM("updatePost", { pathParams: { facilityId, postId }, optimistic: [ { route: "getPost", params: { pathParams: { facilityId, postId } }, updater: (prev, vars) => prev && { ...prev, ...vars.body }, }, { route: "listPosts", params: { pathParams: { facilityId } }, updater: (prev, vars) => prev?.map((p) => (p.id === postId ? { ...p, ...vars.body } : p)), }, ],});Each target gets its own snapshot, so if the request fails, both caches roll back atomically.
Pattern 3: list insert
Section titled “Pattern 3: list insert”For a create flow, you can prepend a placeholder item and let invalidation later swap the placeholder for the real server payload.
const createPost = useM("createPost", { pathParams: { facilityId }, optimistic: [ { route: "listPosts", params: { pathParams: { facilityId } }, updater: (prev, vars) => [ { // Stable enough for the optimistic phase. id: `temp-${crypto.randomUUID()}`, facilityId, createdAt: new Date().toISOString(), ...vars.body, }, ...(prev ?? []), ], }, ],});When the mutation succeeds, useM invalidates the list (via the schema’s invalidatesTags), triggering a refetch — the server’s authoritative payload replaces the temporary item.
Pattern 4: list delete
Section titled “Pattern 4: list delete”Removing optimistically is a one-liner:
const deletePost = useM("deletePost", { pathParams: { facilityId, postId }, optimistic: [ { route: "listPosts", params: { pathParams: { facilityId } }, updater: (prev) => prev?.filter((p) => p.id !== postId), }, { route: "getPost", params: { pathParams: { facilityId, postId } }, updater: () => undefined, // also evict the detail cache }, ],});If the delete fails, both caches restore from snapshot — the user sees the item reappear.
Pattern 5: paginated lists
Section titled “Pattern 5: paginated lists”For infinite/paginated routes, the cache value is InfiniteData<TPage> — { pages: TPage[], pageParams: TPageParam[] }. Update each page that needs touching:
const updatePost = useM("updatePost", { pathParams: { facilityId, postId }, optimistic: [ { route: "listPosts", // paginated params: { pathParams: { facilityId } }, updater: (prev, vars) => { if (!prev) return prev; return { ...prev, pages: prev.pages.map((page) => ({ ...page, items: page.items.map((p) => p.id === postId ? { ...p, ...vars.body } : p, ), })), }; }, }, ],});The same shape applies for inserts (prepend to the first page) and deletes (filter every page).
Cancellation
Section titled “Cancellation”useM calls queryClient.cancelQueries({ queryKey }) for each target before applying the optimistic update. That’s important: if a useQ is mid-fetch when the mutation fires, the in-flight response would otherwise win the race and overwrite your optimistic value.
You don’t have to think about this — it’s automatic — but it’s why the order of operations in onMutate matters.
Why onSettled invalidation matters
Section titled “Why onSettled invalidation matters”After the snapshot is restored (or applied), useM invalidates tags no matter what. Two scenarios:
- Success path: the optimistic value was right (or close); invalidation refetches and replaces with truth. No user-visible flicker.
- Error path: the snapshot rollback put back stale data; invalidation refetches to make sure the user is looking at the real current state.
This is why optimistic + tag-based caches are a powerful combo: you get fast UI and a self-healing system.
When to skip optimistic updates
Section titled “When to skip optimistic updates”Don’t reach for optimistic updates when:
- The user explicitly waits for confirmation (e.g. “Order placed!” with a long-running payment flow).
- The update is hard to render without the server’s response (auto-generated IDs that link to other resources, calculated fields).
- The mutation is rare and a brief spinner is fine. Less code = fewer bugs.
A mutation can pair optimistic updates with useM’s tag invalidation selectively — you can opt into optimism on the easy cases and let the harder cases just refetch.
Debugging tips
Section titled “Debugging tips”- The cache flickers. Usually your
updaterreturnsundefinedfor valid inputs. Useprev && nextrather than mutating-in-place. - The cache stays optimistic after error. The route is missing
invalidatesTags, soonSettleddoesn’t refetch. Make sure the schema chainsinvalidatesTags↔tags. - Two mutations fight. If two mutations target the same cache, the second snapshot is the first’s optimistic value. That’s correct behavior, but consider whether the second mutation should
mutateAsyncto wait for the first.