| | |

TanStack Query 09 ⚛️ Optimistic Updates — Instant UI feedback patterns

⏱️ Estimated read: 14 min | |

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

PatternMechanismRollback RequiredScope
UI-onlymutation.variablesNoSingle component
CacheonMutate + setQueryDataYesAll components reading the key

Cache Pattern Steps

StepCallbackAction
1onMutateawait queryClient.cancelQueries({ queryKey })
2onMutateconst previous = queryClient.getQueryData(queryKey)
3onMutatequeryClient.setQueryData(queryKey, optimisticValue)
4onMutatereturn { previous }
5onErrorqueryClient.setQueryData(queryKey, context.previous)
6onSettledqueryClient.invalidateQueries({ queryKey })

When to Use Each

ScenarioPattern
Add item in the same componentUI-only
Toggle affecting multiple viewsCache
Delete from filtered listsCache
Form submission with temporary displayUI-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

PitfallWhy It HappensFix
UI flickers after optimistic writeRefetch in flight overwrites the cachecancelQueries in onMutate
UI shows change that never happenedNo onError rollbackRestore snapshot from context
Optimistic value persists after server rejectsonSettled not invalidatinginvalidateQueries in onSettled
Cache not updated for other componentsUsed UI-only patternUse cache pattern with setQueryData
Temporary ID conflictsOptimistic item uses Date.now()Server-assigned IDs require invalidation before display
Concurrent mutations overwrite each otherShared cache writes without isolationUse 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

ItemValue
Two patternsUI-only (variables), cache (onMutate)
Cache step 1cancelQueries to prevent races
Cache step 2getQueryData to snapshot
Cache step 3setQueryData to write optimistic value
RollbackonError restores snapshot from context
ReconciliationonSettled invalidates to sync with server
When to use UI-onlySingle component, no cache mutation needed
When to use cacheMultiple components need the optimistic value
When to avoidHigh-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 in onError to roll back .
  • cancelQueries prevents 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 .
  • onSettled reconciles with the server. Even when the mutation succeeds, the server’s response may differ from the optimistic value. Invalidating the query in onSettled replaces 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.variables to 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.


Was this helpful?


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!