A user clicks "Add" and waits half a second for the item to appear. Nothing is broken, but the app feels slow. Optimistic updates fix this: you show the expected result immediately, then confirm or roll back when the server answers.

TanStack Query v5 supports two ways to do this. One is short and safe; the other is more powerful and needs more care. This guide shows both, with the trade-offs, so you can pick the right one per feature.

The setup

We will use a small todo list. The query and mutation functions are plain fetch calls:

typescript
import { queryOptions } from '@tanstack/react-query'

export type Todo = { id: string; title: string; done: boolean }

export const todosQuery = queryOptions({
  queryKey: ['todos'],
  queryFn: async (): Promise<Todo[]> => {
    const res = await fetch('/api/todos')
    if (!res.ok) throw new Error('Failed to load todos')
    return res.json()
  },
})

export async function addTodo(title: string): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })
  if (!res.ok) throw new Error('Failed to add todo')
  return res.json()
}

queryOptions keeps the key and function together and gives you type inference wherever you reuse them, for example in useQuery(todosQuery) or queryClient.getQueryData(todosQuery.queryKey).

Pattern 1: optimistic UI from mutation variables

The simplest approach does not touch the cache at all. While the mutation is pending, useMutation exposes the variables you passed. Render them as a temporary row:

tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

function TodoList() {
  const queryClient = useQueryClient()
  const { data: todos = [] } = useQuery(todosQuery)

  const add = useMutation({
    mutationFn: addTodo,
    onSettled: () => queryClient.invalidateQueries({ queryKey: todosQuery.queryKey }),
  })

  return (
    <ul>
      {todos.map((t) => <li key={t.id}>{t.title}</li>)}
      {add.isPending && <li style={{ opacity: 0.5 }}>{add.variables}</li>}
      {add.isError && (
        <li>
          {add.variables} failed.{' '}
          <button onClick={() => add.mutate(add.variables)}>Retry</button>
        </li>
      )}
    </ul>
  )
}

Why this works well:

  • No rollback code. If the request fails, the pending row simply turns into an error row.
  • Return the promise from `onSettled`. Returning invalidateQueries keeps the mutation pending until the fresh list has loaded, so the temporary row and the real row never both appear.
  • Easy to reason about. The cache only ever contains server data.

The limitation is scope: the optimistic value lives in one component. If another part of the page shows the same list or a counter, it will not update until the refetch finishes.

Pattern 2: optimistic cache update with onMutate

When several components read the same data, update the cache itself. The flow has four steps: cancel in-flight fetches, snapshot the old data, write the optimistic value, and roll back on error.

tsx
const add = useMutation({
  mutationFn: addTodo,
  onMutate: async (title) => {
    // 1. Stop refetches from overwriting our optimistic value
    await queryClient.cancelQueries({ queryKey: todosQuery.queryKey })

    // 2. Snapshot for rollback
    const previous = queryClient.getQueryData(todosQuery.queryKey)

    // 3. Write the optimistic value
    queryClient.setQueryData(todosQuery.queryKey, (old = []) => [
      ...old,
      { id: `temp-${Date.now()}`, title, done: false },
    ])

    // 4. Whatever you return is passed to onError and onSettled
    return { previous }
  },
  onError: (_error, _title, ctx) => {
    if (ctx?.previous) queryClient.setQueryData(todosQuery.queryKey, ctx.previous)
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: todosQuery.queryKey }),
})

Every component reading ['todos'] now updates instantly. Three details matter:

  • `cancelQueries` first. Without it, a background refetch that started earlier can land after your optimistic write and briefly remove the new item.
  • Use a temporary ID. The server assigns the real ID; the invalidation in onSettled replaces the temporary row with the real one.
  • Always invalidate in `onSettled`. It runs on success and failure, so the cache always ends up matching the server.

Which pattern should you use?

  • One place shows the change: use variables. Less code, nothing to roll back.
  • Several components show the change: use onMutate and the cache.
  • The server transforms the data a lot (sorting, computed fields, permissions): prefer variables, or keep the optimistic value minimal, because your guess may not match the result.
  • The action is destructive or expensive (payments, deletes that send email): do not be optimistic. Show a pending state and wait.

Handling concurrent mutations

If a user clicks "Add" three times quickly, three mutations run. With the cache pattern, each onSettled triggers an invalidation, and an early refetch can briefly drop items that are still pending. Two practical fixes:

  • Only invalidate when this is the last running mutation for the key, by checking queryClient.isMutating({ mutationKey: ['addTodo'] }) === 1 in onSettled (give the mutation a mutationKey first).
  • Or use the variables pattern with useMutationState to render every pending mutation, not just the latest one.

Testing optimistic updates

Optimistic code paths are mostly about failure. Test them deliberately:

  • Mock the API to fail and assert that the item disappears (cache pattern) or shows an error (variables pattern).
  • Add an artificial delay and assert the optimistic item is visible before the response.
  • In the browser, use DevTools network throttling and the TanStack Query Devtools to watch cache entries change.

Key takeaways

  • Optimistic updates make interactions feel instant by showing the expected result before the server confirms it.
  • The variables pattern is simplest: render mutation.variables while pending, with no rollback.
  • The onMutate cache pattern updates every component, but you must cancel queries, snapshot and roll back.
  • Always invalidate in onSettled so the UI ends in sync with the server.
  • Skip optimism for destructive or high-stakes actions.

Sources: TanStack Query optimistic updates guide, Mutations guide, useMutation reference.