TanStack Query 09 ⚛️ Optimistic Updates — Instant UI feedback patterns
An optimistic update applies the expected result of a mutation to the UI before the server confirms it. The user clicks “like” and the heart fills immediately. The user adds a todo and it appears in the list instantly. The user deletes an item and it vanishes without waiting for the round trip. The server request runs in the background, and if it fails, the UI rolls back to the previous state. The benefit is perceived latency: the interaction feels instant because the interface responds to the user’s intent rather than to the network’s confirmation.
TanStack Query provides two distinct patterns for this. The simpler pattern uses the mutation’s variables to render a temporary item in the UI without touching the cache. The more powerful pattern manipulates the query cache directly in onMutate, snapshots the previous state, and restores it in onError if the mutation fails . The choice between the two depends on how many places in the UI need to reflect the optimistic change and how much control the application needs over the rollback behavior.
Key point: The cache-manipulation pattern requires three steps in onMutate: cancel outgoing refetches for the affected query key, snapshot the current cache value, and write the optimistic value with setQueryData. The returned snapshot becomes the context argument in onError, where it is used to roll back. The onSettled callback then invalidates the query to reconcile with the server’s authoritative state, regardless of whether the mutation succeeded or failed .
Why optimistic updates require a rollback strategy
The failure problem.
An optimistic update is a prediction. The server may reject the mutation for reasons the client cannot foresee: a validation error, a permission denial, a conflict with another write, or a network failure. When the prediction is wrong, the UI must revert to the state that the server actually holds. Without a rollback, the optimistic value persists in the cache, and the user sees a change that never happened. The next refetch silently corrects it, but until then, the UI is lying .
The race-condition problem.
Between the optimistic write and the server’s response, a background refetch may be in flight. If that refetch resolves after the optimistic write, it overwrites the optimistic value with the old server data, and the UI flickers back to the previous state before the mutation even completes. The cancelQueries call in onMutate closes this race by aborting any in-flight refetches for the affected key before the optimistic write occurs .
The reconciliation problem.
Even when the mutation succeeds, the server’s response may differ from the optimistic value. The server may trim a string, assign a timestamp, compute a total, or normalize the data in some other way. The optimistic value is a guess; the onSettled callback’s invalidateQueries call replaces the guess with the server’s truth. Without this reconciliation, the cache holds the optimistic approximation rather than the actual server state .
a. The UI-only pattern with mutation variables
The simpler pattern does not touch the cache. It uses the mutation’s variables property, which holds the arguments passed to mutate, to render a temporary item in the component that triggers the mutation. The item disappears when the mutation settles, and the refetched query data replaces it.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
function TodoList() {
const queryClient = useQueryClient()
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => addTodo(newTodo),
// Invalidate after settling so the real item appears
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
const { isPending, variables, mutate } = addTodoMutation
return (
<div>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{/* Render the pending item with reduced opacity */}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
<button onClick={() => mutate('New todo')}>Add</button>
</div>
)
}
The variables property holds the argument passed to mutate while the mutation is pending. The temporary item is rendered with reduced opacity to indicate that it is not yet confirmed. When the mutation settles, isPending becomes false, the temporary item is removed, and the invalidated query refetches the actual list from the server . This pattern works well when the mutation and the query live in the same component. If the optimistic item needs to appear in a different component, the useMutationState hook can access the variables of pending mutations from anywhere in the tree .
b. The cache-manipulation pattern with onMutate and rollback
The cache-manipulation pattern writes the optimistic value directly into the query cache. This makes the change visible to every component that reads from that query key, not just the component that triggered the mutation. The trade-off is that the mutation must implement the rollback logic.
function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newTodo: string) => addTodo(newTodo),
onMutate: async (newTodo) => {
// Step 1: Cancel any outgoing refetches
// so they don't overwrite the optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Step 2: Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos'])
// Step 3: Optimistically update the cache
queryClient.setQueryData(['todos'], (old: Todo[] | undefined) => [
...(old ?? []),
{ id: Date.now(), text: newTodo, completed: false },
])
// Return the snapshot as context for rollback
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Roll back to the snapshot if the mutation fails
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// Reconcile with the server after success or failure
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
The onMutate callback runs before the mutation function is called. It cancels refetches, captures the current cache value, and writes the optimistic value. The return value becomes the context argument in onError and onSettled. The onError callback restores the snapshot, undoing the optimistic change. The onSettled callback invalidates the query, which triggers a refetch that replaces both the optimistic value and the rolled-back value with the server’s actual state .
The cancelQueries call is not optional. Without it, a background refetch that was already in flight when the mutation started may resolve after the optimistic write, overwriting it with stale data and causing a flicker. Cancelling the refetches before writing the optimistic value ensures that the cache is not modified by any request other than the mutation itself .
c. Choosing between the two patterns
The choice between the UI-only pattern and the cache-manipulation pattern depends on the scope of the optimistic change and the complexity the application is willing to accept.
The UI-only pattern is appropriate when the optimistic result is shown in only one place, and that place is the component that triggers the mutation. It requires no rollback logic because the temporary item disappears when the mutation settles, regardless of success or failure. The invalidated query then refetches the real data. This pattern is simpler to implement and reason about, and it is the correct choice for forms that add an item to a list rendered in the same component .
The cache-manipulation pattern is necessary when the optimistic change must be visible in multiple places. A “like” button that appears on both a list view and a detail view, a toggle that affects a counter in a header, or a delete that removes an item from several filtered lists all require the cache to be updated, because the component that triggers the mutation is not the only component reading the data. The rollback logic is the cost of this visibility .
Optimistic updates should be reserved for writes that almost always succeed and are low-stakes. Toggles, reorders, adding a tag, and liking a post are good candidates. Payments, irreversible actions, and writes that depend on server-assigned data that the client cannot predict are poor candidates, because the rollback is either impossible or unacceptable .
Complete Example Session
// ============================================
// PART 1: THE QUERY AND MUTATION SETUP
// ============================================
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
interface Todo {
id: number
text: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
}
async function toggleTodo(todo: Todo): Promise<Todo> {
const response = await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
})
if (!response.ok) throw new Error('Failed to toggle')
return response.json()
}
// ============================================
// PART 2: THE OPTIMISTIC TOGGLE MUTATION
// ============================================
function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: toggleTodo,
onMutate: async (toggledTodo) => {
// Cancel refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot the current list
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistically toggle the todo in the cache
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === toggledTodo.id
? { ...todo, completed: !todo.completed }
: todo
)
)
// Return the snapshot for rollback
return { previousTodos }
},
onError: (err, toggledTodo, context) => {
// Restore the snapshot on failure
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// Reconcile with the server after success or failure
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
// ============================================
// PART 3: THE COMPONENT USING THE MUTATION
// ============================================
function TodoList() {
const { data: todos, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
const toggleMutation = useToggleTodo()
if (isPending) return <p>Loading...</p>
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate(todo)}
/>
{todo.text}
</label>
</li>
))}
</ul>
)
}
// ============================================
// PART 4: THE UI-ONLY PATTERN FOR COMPARISON
// ============================================
function AddTodoForm() {
const queryClient = useQueryClient()
const addMutation = useMutation({
mutationFn: (text: string) => addTodo(text),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
const { isPending, variables, mutate } = addMutation
return (
<div>
<button onClick={() => mutate('New todo')}>Add</button>
{isPending && <p style={{ opacity: 0.5 }}>Adding: {variables}</p>}
</div>
)
}
The four parts demonstrate the cache-manipulation pattern for a toggle and the UI-only pattern for an add. The toggle requires cache manipulation because the checkbox state is rendered from the todos query, and the optimistic change must be visible immediately. The add uses the UI-only pattern because the temporary item appears in the same component that triggers the mutation, and the invalidation refetches the real list.
Quick Reference
Two Patterns
| Pattern | Mechanism | Rollback Required | Scope |
|---|---|---|---|
| UI-only | mutation.variables | No | Single component |
| Cache | onMutate + setQueryData | Yes | All components reading the key |
Cache Pattern Steps
| Step | Callback | Action |
|---|---|---|
| 1 | onMutate | await queryClient.cancelQueries({ queryKey }) |
| 2 | onMutate | const previous = queryClient.getQueryData(queryKey) |
| 3 | onMutate | queryClient.setQueryData(queryKey, optimisticValue) |
| 4 | onMutate | return { previous } |
| 5 | onError | queryClient.setQueryData(queryKey, context.previous) |
| 6 | onSettled | queryClient.invalidateQueries({ queryKey }) |
When to Use Each
| Scenario | Pattern |
|---|---|
| Add item in the same component | UI-only |
| Toggle affecting multiple views | Cache |
| Delete from filtered lists | Cache |
| Form submission with temporary display | UI-only |
| High-stakes write (payment) | Neither (pending-then-invalidate) |
Best Practices
✅ Do This:
// Cancel refetches before the optimistic write
await queryClient.cancelQueries({ queryKey: ['todos'] }) # ✅
// Snapshot before writing
const previous = queryClient.getQueryData(['todos']) # ✅
// Return the snapshot as context
return { previous } # ✅
// Restore the snapshot on error
onError: (err, vars, context) => {
queryClient.setQueryData(['todos'], context?.previous)
} # ✅
// Invalidate on settled to reconcile
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) # ✅
// Use immutable updates in setQueryData
queryClient.setQueryData(['todos'], (old) => [...old, newItem]) # ✅
❌ Don’t Do This:
// Skip cancelQueries
onMutate: (newTodo) => { queryClient.setQueryData(...) } # ❌ Race condition
// Skip the snapshot and rollback
onMutate: (newTodo) => { queryClient.setQueryData(...) } # ❌ No undo
// Mutate the cache directly
queryClient.getQueryData(['todos']).push(newTodo) # ❌ No new reference
// Invalidate only on success
onSuccess: () => queryClient.invalidateQueries(...) # ❌ No reconcile on error
// Use optimistic updates for server-assigned IDs
// The temporary ID is wrong and causes key conflicts # ❌
// Use optimistic updates for irreversible actions
// A failed payment cannot be rolled back in the UI # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI flickers after optimistic write | Refetch in flight overwrites the cache | cancelQueries in onMutate |
| UI shows change that never happened | No onError rollback | Restore snapshot from context |
| Optimistic value persists after server rejects | onSettled not invalidating | invalidateQueries in onSettled |
| Cache not updated for other components | Used UI-only pattern | Use cache pattern with setQueryData |
| Temporary ID conflicts | Optimistic item uses Date.now() | Server-assigned IDs require invalidation before display |
| Concurrent mutations overwrite each other | Shared cache writes without isolation | Use submittedAt or unique temporary IDs |
Real-World Examples
1. Optimistic toggle
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => t.id === todo.id ? { ...t, completed: !t.completed } : t)
)
return { previous }
}
2. Optimistic delete
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => old.filter((t) => t.id !== id))
return { previous }
}
3. Optimistic add with cache
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, { id: Date.now(), text }])
return { previous }
}
4. UI-only temporary item
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
5. Rollback in onError
onError: (err, vars, context) => {
queryClient.setQueryData(['todos'], context?.previous)
}
6. Reconcile in onSettled
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] })
7. Cancel before snapshot
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
8. Immutable cache update
queryClient.setQueryData(['todos'], (old) => [...old, newItem])
9. Access pending variables elsewhere
const pending = useMutationState({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (m) => m.state.variables,
})
10. Single-item cache update
queryClient.setQueryData(['todos', id], (old) => ({ ...old, text: newText }))
Visual
The Optimistic Update Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ CANCEL → SNAPSHOT → WRITE → (ERROR: ROLLBACK) → RECONCILE │
│ │
│ mutate() called │
│ │ │
│ ▼ │
│ onMutate │
│ ├── cancelQueries(['todos']) │
│ ├── previous = getQueryData(['todos']) │
│ ├── setQueryData(['todos'], optimistic) │
│ └── return { previous } │
│ │ │
│ ▼ │
│ mutationFn runs (server request) │
│ │ │
│ ├── success → cache keeps optimistic value │
│ │ │
│ └── failure → onError │
│ └── setQueryData(['todos'], context.previous) │
│ │ │
│ ▼ │
│ onSettled │
│ └── invalidateQueries(['todos']) │
│ └── refetch replaces cache with server truth │
└──────────────────────────────────────────────────────────────┘
UI-Only vs Cache Pattern
┌──────────────────────────────────────────────────────────────┐
│ WHERE THE OPTIMISTIC VALUE LIVES │
│ │
│ UI-ONLY: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ mutation.variables │ │
│ │ → rendered in the component that triggers the mutation │ │
│ │ → disappears on settle │ │
│ │ → no rollback needed │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ CACHE: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ queryClient.setQueryData │ │
│ │ → visible to every component reading the key │ │
│ │ → persists until invalidated or rolled back │ │
│ │ → rollback required in onError │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Race Condition Without cancelQueries
┌──────────────────────────────────────────────────────────────┐
│ WHY cancelQueries IS REQUIRED │
│ │
│ T=0: Background refetch starts (e.g., window focus) │
│ T=1: mutate() called → onMutate writes optimistic value │
│ T=2: Background refetch resolves with OLD server data │
│ → Cache is overwritten with stale data │
│ → UI flickers back to previous state │
│ T=3: mutationFn resolves with new data │
│ → UI flickers forward again │
│ │
│ WITH cancelQueries: │
│ T=1: onMutate cancels the background refetch │
│ → No stale data overwrites the optimistic value │
│ → UI stays consistent until the mutation settles │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Two patterns | UI-only (variables), cache (onMutate) |
| Cache step 1 | cancelQueries to prevent races |
| Cache step 2 | getQueryData to snapshot |
| Cache step 3 | setQueryData to write optimistic value |
| Rollback | onError restores snapshot from context |
| Reconciliation | onSettled invalidates to sync with server |
| When to use UI-only | Single component, no cache mutation needed |
| When to use cache | Multiple components need the optimistic value |
| When to avoid | High-stakes writes, server-assigned IDs |
Key takeaways:
- Optimistic updates trade correctness for latency. The UI responds instantly, but the application must handle the case where the server rejects the prediction. The rollback is the mechanism that restores correctness when the prediction is wrong .
- The cache pattern requires three steps in
onMutate. Cancel refetches, snapshot the previous value, and write the optimistic value. The snapshot is returned as context and used inonErrorto roll back . cancelQueriesprevents the race condition. Without it, a background refetch that resolves after the optimistic write overwrites the optimistic value with stale data, causing a flicker. Cancelling the refetches before writing ensures that only the mutation modifies the cache .onSettledreconciles with the server. Even when the mutation succeeds, the server’s response may differ from the optimistic value. Invalidating the query inonSettledreplaces the guess with the server’s truth, regardless of whether the mutation succeeded or failed .- The UI-only pattern is simpler but limited. It uses
mutation.variablesto render a temporary item in the component that triggers the mutation. It requires no rollback logic because the item disappears when the mutation settles. It is the correct choice when the optimistic result is visible in only one place . - Reserve optimism for low-stakes writes. Toggles, reorders, and additions that almost always succeed are good candidates. Payments, irreversible actions, and writes that depend on server-assigned data are poor candidates, because the rollback is either impossible or unacceptable .
Remember: An optimistic update is a prediction that the server will accept the write. The UI shows the predicted result immediately, and the application must be prepared to undo it if the prediction is wrong. The cache pattern provides the undo through the onMutate snapshot and the onError rollback. The onSettled invalidation reconciles the cache with the server’s actual state. Use the UI-only pattern for single-component additions, the cache pattern for changes that affect multiple views, and neither for writes that cannot be safely undone.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!