web/.agents/skills/tanstack-query-best-practices/rules/mut-optimistic-updates.md
Optimistic updates immediately reflect changes in the UI before the server confirms them, creating a snappy user experience. Implement them for user-initiated mutations where the expected outcome is predictable.
// No optimistic update - UI waits for server response
const mutation = useMutation({
mutationFn: toggleTodoComplete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// User clicks checkbox, waits 200-500ms for visual feedback
const mutation = useMutation({
mutationFn: toggleTodoComplete,
onMutate: async (todoId) => {
// 1. Cancel outgoing refetches to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot previous value for potential rollback
const previousTodos = queryClient.getQueryData(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
)
)
// 4. Return context for rollback
return { previousTodos }
},
onError: (err, todoId, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// Refetch to ensure consistency regardless of success/failure
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// When mutation only affects local UI, use mutation state directly
function TodoItem({ todo }: { todo: Todo }) {
const mutation = useMutation({
mutationFn: toggleTodoComplete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// Show optimistic state while pending
const displayCompleted = mutation.isPending
? !todo.completed // Optimistic: show toggled state
: todo.completed // Settled: show actual state
return (
<div>
<input
type="checkbox"
checked={displayCompleted}
disabled={mutation.isPending}
onChange={() => mutation.mutate(todo.id)}
/>
<span style={{ opacity: mutation.isPending ? 0.5 : 1 }}>
{todo.title}
</span>
</div>
)
}
const createTodo = useMutation({
mutationFn: (newTodo: CreateTodoInput) => api.createTodo(newTodo),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
// Add with temporary ID
const optimisticTodo = {
id: `temp-${Date.now()}`,
...newTodo,
completed: false,
createdAt: new Date().toISOString(),
}
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, optimisticTodo])
return { previousTodos, optimisticTodo }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSuccess: (data, variables, context) => {
// Replace temp todo with real one
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((todo) =>
todo.id === context?.optimisticTodo.id ? data : todo
)
)
},
})
| Approach | Use When |
|---|---|
| Cache Manipulation | Update appears in multiple places, complex data structures |
| UI Variables | Update only visible in one component, simpler implementation |
onErrorinvalidateQueries in onSettled to sync with server truth