frontend

TanStack Query Optimistic Updates: Rollback in 2026

June 24, 2026

TanStack Query Optimistic Updates: Rollback in 2026

A TanStack Query optimistic update writes the expected result into the cache the moment a user acts, then reconciles with the server when the mutation settles. You do it in useMutation's onMutate: cancel refetches, snapshot the cache, write with setQueryData, and return the snapshot so onError can roll back.

TL;DR

We build a small, fully typed todo app with a mock async API and add React Query optimistic updates with useMutation. You'll see the cache approach (onMutatesetQueryData → rollback), the simpler "via the UI" approach using mutation.variables, and when to reach for each. Every code block in this tutorial was type-checked against @tanstack/react-query@5.101.1 and the optimistic-then-rollback behaviour was verified at runtime. Budget about 20 minutes.

What you'll learn

  • How to scaffold a Vite + React + TypeScript app with a QueryClientProvider
  • How to read a list with useQuery and a mock async API
  • Why a naive mutation makes the list flicker, and how cancelQueries fixes the race
  • How to do an optimistic cache update with onMutate, setQueryData, and a snapshot
  • How to roll back automatically with onError and resync with onSettled
  • The simpler "via the UI" approach using mutation.variables and isPending
  • The newer v5 callback form that passes context.client
  • When to use TanStack Query onMutate versus React 19's useOptimistic

Prerequisites

  • Node 20.19+ or 22.12+ (the versions Vite 8 declares in its engines field)
  • React 19.2 and react-dom 19.2 (TanStack Query v5 lists a peer range of ^18 || ^19)1
  • @tanstack/react-query 5.101.1 (latest at the time of writing)2
  • TypeScript 6.0 and basic familiarity with useQuery

Step 1 — Scaffold the project and wrap it in a QueryClientProvider

Create a Vite React + TypeScript project and add TanStack Query with pinned versions:

# create-vite 9.1.0 scaffolds Vite 8, React 19.2, and TypeScript 6
npm create vite@9.1.0 rq-optimistic -- --template react-ts
cd rq-optimistic
npm install
npm install @tanstack/react-query@5.101.1

Every component that calls a query or mutation must sit under a QueryClientProvider. Wire it up once at the root in src/main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
)

Step 2 — A mock async API and a list with useQuery

Optimistic updates only make sense against latency, so the mock API in src/api.ts sleeps before resolving and exposes a setShouldFail switch so we can exercise the rollback path on demand:

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

let store: Todo[] = [{ id: '1', text: 'Learn TanStack Query', done: false }]
export let shouldFail = false
export function setShouldFail(v: boolean) { shouldFail = v }

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

export async function fetchTodos(): Promise<Todo[]> {
  await sleep(300)
  return structuredClone(store)
}

export async function addTodo(text: string): Promise<Todo> {
  await sleep(600)
  if (shouldFail) throw new Error('Server rejected the new todo')
  const todo: Todo = { id: crypto.randomUUID(), text, done: false }
  store = [...store, todo]
  return todo
}

export async function toggleTodo(id: string): Promise<Todo> {
  await sleep(600)
  if (shouldFail) throw new Error('Server rejected the toggle')
  store = store.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
  const updated = store.find((t) => t.id === id)
  if (!updated) throw new Error('Not found')
  return updated
}

Reading the list is ordinary useQuery. Note that in v5 a query's no-data state is exposed as isPending (the pending status), and error is typed once you've narrowed isError:3

const todosQuery = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

if (todosQuery.isPending) return <p>Loading…</p>
if (todosQuery.isError) return <p>Error: {todosQuery.error.message}</p>

Step 3 — Why cancelQueries comes first (the race)

Here is the trap that makes a "working" optimistic update flicker. The instant you call mutate, a background refetch of ['todos'] may already be in flight. If that refetch resolves after your optimistic setQueryData, its stale server response overwrites your optimistic value and the new item briefly disappears.

That is exactly why the optimistic-update guide tells you to await queryClient.cancelQueries({ queryKey }) first: it cancels outgoing refetches so they don't overwrite your optimistic write.4 Treat cancelQueries as step zero of every cache-based optimistic update — skipping it is a common reason an optimistic update "doesn't stick."

Step 4 — Optimistic update via the cache

Now the real thing. In src/App.tsx, the add mutation cancels refetches, snapshots the cache, writes an optimistic todo, and returns the snapshot. The returned value is handed to onError and onSettled later, which is what makes rollback possible:4

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { addTodo, fetchTodos, toggleTodo, type Todo } from './api'

const todosKey = ['todos'] as const

export function App() {
  const queryClient = useQueryClient()
  const [text, setText] = useState('')

  const todosQuery = useQuery({ queryKey: todosKey, queryFn: fetchTodos })

  const addMutation = useMutation({
    mutationFn: addTodo,
    onMutate: async (newText: string) => {
      await queryClient.cancelQueries({ queryKey: todosKey })
      const previousTodos = queryClient.getQueryData<Todo[]>(todosKey)
      const optimistic: Todo = { id: `temp-${Date.now()}`, text: newText, done: false }
      queryClient.setQueryData<Todo[]>(todosKey, (old) => [...(old ?? []), optimistic])
      return { previousTodos }
    },
    onError: (_err, _newText, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(todosKey, context.previousTodos)
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: todosKey })
    },
  })
  // …render below
}

A few details that the type-checker cares about. getQueryData<Todo[]> returns Todo[] | undefined, so the setQueryData updater guards with old ?? []. The optimistic item uses a temporary id (temp-…) because the real server id doesn't exist yet — onSettled's invalidation refetches and replaces it with the canonical record.

Step 5 — Roll back on error, resync on settle

The three callbacks divide the work cleanly:

  • onMutate runs before the request and returns the rollback context (your snapshot).
  • onError receives that snapshot as its third argument and restores it with setQueryData. The optional chaining (context?.previousTodos) is required because onMutate could in principle return nothing.
  • onSettled runs after success or error and calls invalidateQueries, so the cache is reconciled with the server after every mutation — your optimistic guess is never the final word.

The same three callbacks handle a single-item update. Declare a toggle mutation right next to the add mutation in the component — it snapshots, rewrites just the affected row, and rolls back the whole list on failure:

const toggleMutation = useMutation({
  mutationFn: toggleTodo,
  onMutate: async (id: string) => {
    await queryClient.cancelQueries({ queryKey: todosKey })
    const previousTodos = queryClient.getQueryData<Todo[]>(todosKey)
    queryClient.setQueryData<Todo[]>(todosKey, (old) =>
      (old ?? []).map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    )
    return { previousTodos }
  },
  onError: (_err, _id, context) => {
    if (context?.previousTodos) {
      queryClient.setQueryData(todosKey, context.previousTodos)
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: todosKey })
  },
})

Finally, wire the form and list to finish App.tsx. Each list item calls the toggle on click:

  if (todosQuery.isPending) return <p>Loading…</p>
  if (todosQuery.isError) return <p>Error: {todosQuery.error.message}</p>

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (!text.trim()) return
          addMutation.mutate(text.trim())
          setText('')
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button type="submit" disabled={addMutation.isPending}>Add</button>
      </form>
      <ul>
        {todosQuery.data.map((todo) => (
          <li key={todo.id} onClick={() => toggleMutation.mutate(todo.id)}>
            {todo.done ? '✓ ' : '○ '}{todo.text}
          </li>
        ))}
      </ul>
    </div>
  )

Step 6 — The simpler "via the UI" approach

If only one component needs to show the pending item, you don't have to touch the cache at all. TanStack Query keeps the in-flight variables on the mutation object, so you can render a temporary row while isPending is true and let invalidation clean it up. There's no setQueryData and no rollback to write:4

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { addTodo } from './api'

export function useAddTodoViaUI() {
  const queryClient = useQueryClient()
  const mutation = useMutation({
    mutationFn: addTodo,
    // return the promise so the mutation stays pending until the refetch finishes
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
    mutationKey: ['addTodo'],
  })
  const { isPending, submittedAt, variables, isError, mutate } = mutation
  return { isPending, submittedAt, variables, isError, mutate }
}

Two things make this approach pleasant. First, you must return the invalidateQueries promise from onSettled so the mutation stays in its pending state until the refetch completes — otherwise the temporary row vanishes before the real one arrives. Second, variables are not cleared when the mutation errors, so you can keep the failed item on screen with a retry button.4 If the mutation lives in a different component than the list, read the in-flight variables anywhere with useMutationState (this is why we set a mutationKey):

import { useMutationState } from '@tanstack/react-query'

export function usePendingNewTodos() {
  return useMutationState<string>({
    filters: { mutationKey: ['addTodo'], status: 'pending' },
    select: (mutation) => mutation.state.variables as string,
  })
}

Use the UI approach when there's a single render site and you'd rather not own rollback logic; use the cache approach when several components read the same query and all of them should reflect the change at once.4

Step 7 — The newer context.client callback form

Recent v5 releases pass a context object as the final argument to every mutation callback, with context.client being the QueryClient. That lets you write optimistic logic without closing over useQueryClient(), and it renames the onMutate return value to onMutateResult in the signature:3

import { useMutation } from '@tanstack/react-query'
import { addTodo, type Todo } from './api'

export function useAddTodoViaContext() {
  return useMutation({
    mutationFn: addTodo,
    onMutate: async (newText: string, context) => {
      await context.client.cancelQueries({ queryKey: ['todos'] })
      const previousTodos = context.client.getQueryData<Todo[]>(['todos'])
      context.client.setQueryData<Todo[]>(['todos'], (old) => [
        ...(old ?? []),
        { id: `temp-${Date.now()}`, text: newText, done: false },
      ])
      return { previousTodos }
    },
    onError: (_err, _newText, onMutateResult, context) => {
      if (onMutateResult?.previousTodos) {
        context.client.setQueryData(['todos'], onMutateResult.previousTodos)
      }
    },
    onSettled: (_d, _e, _v, _r, context) =>
      context.client.invalidateQueries({ queryKey: ['todos'] }),
  })
}

Both forms compile and behave identically. The closure pattern from Step 4 (naming the third onError argument context and reading the QueryClient from the component scope) keeps working because that third positional argument is still the onMutate return value — so you can adopt context.client when convenient without rewriting existing mutations.

Verification

Type-check and build:

npx tsc --noEmit   # exits 0
npx vite build     # exits 0
npm run dev        # then open the printed localhost URL

What to expect when you run it:

  • Add a todo: the new row appears instantly (before the 600 ms request resolves), then onSettled invalidation refetches and the temporary temp-… id is replaced by the server's real id.
  • Force a failure: call setShouldFail(true) from api.ts, add a todo, and the optimistic row appears and then disappears as onError restores the snapshot. The list returns exactly to its prior state.

You can prove the rollback without a browser by driving the lifecycle headlessly with a QueryClient and MutationObserver. On the success path the cache grows from 1 to 2 entries; on the failure path it returns to 1 — the snapshot restore confirmed.

Troubleshooting

  • The optimistic item flashes then disappears, then reappears. A background refetch resolved after your setQueryData and clobbered it. You're missing await queryClient.cancelQueries({ queryKey }) at the top of onMutate.4
  • setQueryData complains that old is possibly undefined. getQueryData/the updater can receive undefined if the query hasn't loaded. Guard with old ?? [] and type the call as setQueryData<Todo[]>.
  • Rollback never runs. Confirm onMutate actually returns the snapshot object — if it returns undefined, the third callback argument is undefined and there's nothing to restore. Keep the context?.previousTodos guard.
  • The pending "via the UI" row vanishes too early. You forgot to return the invalidateQueries promise from onSettled; without it the mutation leaves the pending state before the refetch lands.4
  • A retry on a failed item does nothing useful. Remember variables survive an error, so mutate(variables) re-runs the same input from a retry button.4

TanStack Query onMutate vs React 19 useOptimistic

These are different tools that coexist in 2026. React 19's built-in useOptimistic hook gives component-local optimistic UI tied to actions and transitions: its optimistic layer reverts automatically when the action settles, but it doesn't manage a shared server cache across components the way TanStack Query's cache does.5 Reach for useOptimistic for form- and transition-driven local echoes (often alongside Server Actions, as in our React 19 useOptimistic tutorial); reach for TanStack Query onMutate when the optimistic change must be reflected everywhere that reads the same cached query and reconciled with the server afterwards.

Next steps and further reading

Footnotes

  1. @tanstack/react-query@5.101.1 peerDependencies — npm registry, retrieved 2026-06-24. https://www.npmjs.com/package/@tanstack/react-query

  2. @tanstack/react-query dist-tags (latest = 5.101.1) — npm registry, retrieved 2026-06-24. https://www.npmjs.com/package/@tanstack/react-query

  3. "Mutations" — TanStack Query v5 React docs (mutation states, callback signatures, context.client), retrieved 2026-06-24. https://tanstack.com/query/latest/docs/framework/react/guides/mutations 2

  4. "Optimistic Updates" — TanStack Query v5 React docs, retrieved 2026-06-24. https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates 2 3 4 5 6 7 8 9

  5. "useOptimistic" — React documentation. https://react.dev/reference/react/useOptimistic