TanStack Query Optimistic Updates: Rollback in 2026
June 24, 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 (onMutate → setQueryData → 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
useQueryand a mock async API - Why a naive mutation makes the list flicker, and how
cancelQueriesfixes the race - How to do an optimistic cache update with
onMutate,setQueryData, and a snapshot - How to roll back automatically with
onErrorand resync withonSettled - The simpler "via the UI" approach using
mutation.variablesandisPending - The newer v5 callback form that passes
context.client - When to use TanStack Query
onMutateversus React 19'suseOptimistic
Prerequisites
- Node 20.19+ or 22.12+ (the versions Vite 8 declares in its
enginesfield) - 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:
onMutateruns before the request and returns the rollback context (your snapshot).onErrorreceives that snapshot as its third argument and restores it withsetQueryData. The optional chaining (context?.previousTodos) is required becauseonMutatecould in principle return nothing.onSettledruns after success or error and callsinvalidateQueries, 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
onSettledinvalidation refetches and the temporarytemp-…id is replaced by the server's real id. - Force a failure: call
setShouldFail(true)fromapi.ts, add a todo, and the optimistic row appears and then disappears asonErrorrestores 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
setQueryDataand clobbered it. You're missingawait queryClient.cancelQueries({ queryKey })at the top ofonMutate.4 setQueryDatacomplains thatoldis possibly undefined.getQueryData/the updater can receiveundefinedif the query hasn't loaded. Guard withold ?? []and type the call assetQueryData<Todo[]>.- Rollback never runs. Confirm
onMutateactuallyreturns the snapshot object — if it returnsundefined, the third callback argument isundefinedand there's nothing to restore. Keep thecontext?.previousTodosguard. - The pending "via the UI" row vanishes too early. You forgot to return the
invalidateQueriespromise fromonSettled; without it the mutation leaves the pending state before the refetch lands.4 - A retry on a failed item does nothing useful. Remember
variablessurvive an error, somutate(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
- Pair optimistic mutations with server-side data loading — see prefetching TanStack Query in the Next.js App Router.
- Validate the form that feeds your mutation with TanStack Form and Zod.
- For concurrent optimistic updates across many in-flight mutations, read TkDodo's deep dive linked from the official guide.4
Footnotes
-
@tanstack/react-query@5.101.1peerDependencies— npm registry, retrieved 2026-06-24. https://www.npmjs.com/package/@tanstack/react-query ↩ -
@tanstack/react-querydist-tags (latest = 5.101.1) — npm registry, retrieved 2026-06-24. https://www.npmjs.com/package/@tanstack/react-query ↩ -
"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 -
"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
-
"useOptimistic" — React documentation. https://react.dev/reference/react/useOptimistic ↩