Execution contract
- Use when
- Use when mutation latency is noticeable, immediate feedback matters, and old cache can be snapshotted and restored exactly.
- Avoid when
- Show pending variables or wait for the response when complex permissions, server ordering, or multi-list rules cannot be reproduced accurately.
- Preconditions
- List all affected query keys, snapshot types, conflict responses, rollback policy, and final revalidation scope.
- Verification
- Cover success, network failure, validation error, conflict, and concurrent submission; every path converges on server state without a stale refetch overwriting the draft.
- Failure mode
- A flash back to old data usually means onMutate did not await cancelQueries; a draft surviving failure indicates a missing snapshot, in-place mutation, or mismatched rollback key.
- Security
- Optimistic UI is not proof of persistence or authorization; permission denial and domain conflicts must roll back, and sensitive operations should not appear completed prematurely.
Decide whether cache mutation is necessary
For one view, render mutation.variables while isPending; this is the lower-risk default. Use onMutate only when multiple cache consumers must update immediately. List exact query keys, old-value types, server failure semantics, and final revalidation scope.
Transactional implementation
onMutate must await cancelQueries before snapshotting and writing a new object. onError restores only when a snapshot exists. Return the invalidateQueries promise from onSettled so pending covers the full reconciliation window. Never mutate old cache in place.
import { mutationOptions } from '@tanstack/react-query'
const issueKey = (id: string) => ['issues', 'detail', id] as const
export const renameIssueOptions = (issueId: string) => mutationOptions({
mutationFn: (title: string) => renameIssue({ id: issueId, title }),
onMutate: async (title, context) => {
const key = issueKey(issueId)
await context.client.cancelQueries({ queryKey: key })
const previous = context.client.getQueryData<Issue>(key)
context.client.setQueryData<Issue>(key, (issue) =>
issue ? { ...issue, title } : issue,
)
return { previous }
},
onError: (_error, _title, result, context) => {
if (result) context.client.setQueryData(issueKey(issueId), result.previous)
},
onSettled: (_data, _error, _title, _result, context) =>
context.client.invalidateQueries({ queryKey: issueKey(issueId) }),
})Concurrency and authorization
One mutationKey may expose several pending variables, so use submittedAt to identify temporary entries. Only the server response proves persistence and authorization; conflicts, validation failures, and permission denials must roll back or refetch rather than leaving optimistic cache as truth.