---
title: 'TanStack Query Optimistic Updates: Rollback in 2026'
description: 'Optimistic updates in TanStack Query (React Query) with useMutation: cancelQueries, snapshot, setQueryData, and automatic rollback when a request fails.'
url: https://nerdleveltech.com/tanstack-query-optimistic-updates-rollback-tutorial
language: en
date: '2026-06-24'
category: frontend
tags:
  - tanstack query
  - react query
  - optimistic updates
  - usemutation
  - react
  - typescript
  - frontend
  - tutorial
markdown_url: https://nerdleveltech.com/tanstack-query-optimistic-updates-rollback-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/tanstack-query-optimistic-updates-rollback-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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 (`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 `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`)[^peer]
- **@tanstack/react-query 5.101.1** (latest at the time of writing)[^npm]
- **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:

```bash
# 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`:

```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:

```ts
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`:[^mutations]

```tsx
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.[^optimistic] 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:[^optimistic]

```tsx
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:

```tsx
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:

```tsx
  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:[^optimistic]

```tsx
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.[^optimistic] 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`):

```tsx
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.[^optimistic]

## 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:[^mutations]

```tsx
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:

```bash
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`.[^optimistic]
- **`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 `return`s 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.[^optimistic]
- **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.[^optimistic]

## 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.[^react19] Reach for `useOptimistic` for form- and transition-driven local echoes (often alongside Server Actions, as in our [React 19 useOptimistic tutorial](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-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](https://nerdleveltech.com/tanstack-query-nextjs-app-router-prefetch-tutorial).
- Validate the form that feeds your mutation with [TanStack Form and Zod](https://nerdleveltech.com/tanstack-form-react-zod-validation-tutorial).
- For concurrent optimistic updates across many in-flight mutations, read TkDodo's deep dive linked from the official guide.[^optimistic]

[^peer]: `@tanstack/react-query@5.101.1` `peerDependencies` — npm registry, retrieved 2026-06-24. <https://www.npmjs.com/package/@tanstack/react-query>
[^npm]: `@tanstack/react-query` dist-tags (`latest = 5.101.1`) — npm registry, retrieved 2026-06-24. <https://www.npmjs.com/package/@tanstack/react-query>
[^optimistic]: "Optimistic Updates" — TanStack Query v5 React docs, retrieved 2026-06-24. <https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates>
[^mutations]: "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>
[^react19]: "useOptimistic" — React documentation. <https://react.dev/reference/react/useOptimistic>
