Skip to content

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.

For each OptimisticTarget:

  1. cancelQueries — prevent any in-flight refetch of the target from clobbering your optimistic value.
  2. getQueryData — snapshot the previous value.
  3. setQueryData — apply the optimistic update.
  4. On onError — restore each snapshot (in reverse order, so layered updates unwind correctly).
  5. On onSettleduseM runs tag invalidation, which refetches the truth.

You write the updater. Everything else is handled.

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.

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.

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.

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.

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).

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.

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.

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.

  • The cache flickers. Usually your updater returns undefined for valid inputs. Use prev && next rather than mutating-in-place.
  • The cache stays optimistic after error. The route is missing invalidatesTags, so onSettled doesn’t refetch. Make sure the schema chains invalidatesTagstags.
  • 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 mutateAsync to wait for the first.