Learning TanStack Query - Optimistic Updates
Episode 11 of 23

Learning TanStack Query - Optimistic Updates

This episode teaches optimistic UI: updating the cache before the mutation finishes with onMutate and setQueryData, then automatic rollback in onError. You also see real use cases such as likes, status toggles, and inline forms.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

When a user presses a like button, waiting half a second before seeing the icon change feels slow. The best experience: the icon changes instantly, and if the server rejects, the change is smoothly rolled back. This is an optimistic update — trusting that the operation will succeed, updating the UI first, and correcting yourself if it actually fails.

Episode 11 breaks down the optimistic update pattern in TanStack Query: update the cache in onMutate, save the old data for rollback, and restore the data when onError occurs.

The Optimistic UI Concept

The Correct Order

An optimistic update changes the order of operations: the UI changes before the server responds. TanStack Query supports this through three useMutation callbacks:

Optimistic update callback order
onMutate  → update the cache first (predicting success)
mutationFn → send to the server
onError   → roll back the cache to the old data
onSettled → invalidate / refetch for final consistency

The onMutate update the cache first diagram shows the heart of this pattern: the UI is updated before mutationFn finishes. onError provides the path back if the server rejects, and onSettled cleans up the final state.

The Complete Status Toggle Pattern

The most classic example: toggling the completed status of a todo. When the user checks the box, the UI changes immediately; if the server fails, the check is pulled back:

JSOptimistic todo toggle
import { useMutation, useQueryClient } from "@tanstack/react-query"
 
function TodoItem({ todo }) {
  const queryClient = useQueryClient()
 
  const toggle = useMutation({
    mutationFn: (newTodo) =>
      fetch(`https://jsonplaceholder.typicode.com/todos/${todo.id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newTodo),
      }).then((res) => res.json()),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] })
      const previous = queryClient.getQueryData(["todos"])
      queryClient.setQueryData(["todos"], (old) =>
        old.map((t) => (t.id === newTodo.id ? newTodo : t))
      )
      return { previous }
    },
    onError: (error, _newTodo, context) => {
      if (context?.previous) {
        queryClient.setQueryData(["todos"], context.previous)
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] })
    },
  })
 
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggle.mutate({ ...todo, completed: !todo.completed })}
      />
      {todo.title}
    </label>
  )
}

The flow is layered. onMutate cancels any running queries so they don't overwrite our update, saves previous into the context, then setQueryData immediately replaces the todo's status. onError restores previous into the cache, and onSettled invalidates so the final data always matches the server. context is the value returned from onMutate and passed to onError and onSettled.

Why cancelQueries Matters

cancelQueries cancels running refetches. Without it, a background refetch could overwrite the optimistic update with old data that doesn't reflect the change yet. await queryClient.cancelQueries({ queryKey: ["todos"] }) ensures the cache is quiet before we write new data.

Optimistic Update Use Cases

Likes and Toggles

Interactions like likes, bookmarks, and toggles are perfect candidates: the operation is fast, the result is certain, and instant feedback is highly appreciated by users. The pattern above can be applied directly.

Inline Forms

Forms that can be edited in place — renaming a title, changing a description — also fit. The UI shows the new value instantly, and a rollback only happens if server validation rejects:

JSInline title update
const rename = useMutation({
  mutationFn: updateTitle,
  onMutate: async ({ id, title }) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] })
    const previous = queryClient.getQueryData(["todos"])
    queryClient.setQueryData(["todos"], (old) =>
      old.map((t) => (t.id === id ? { ...t, title } : t))
    )
    return { previous }
  },
  onError: (error, _vars, context) => {
    if (context?.previous) {
      queryClient.setQueryData(["todos"], context.previous)
    }
  },
})

Drag and Drop

For reordering items, an optimistic update makes the item's position change instantly when the drag finishes, then the new order is sent to the server. If it fails, the order is restored to its original positions.

Rollback and Error Handling

Context as the Key

The key to reliable rollback is context: the value returned by onMutate is stored, then used to restore the cache in onError. Never store old data in an outer closure — context guarantees that the data being restored is exactly right for the mutation that failed.

Final Consistency with onSettled

An optimistic update must still end with consistency. onSettled runs whether the operation succeeds or fails, and the invalidateQueries there ensures the final cache always reflects the server — while also closing the door on small differences between prediction and reality.

Warning

Optimistic updates are not for every mutation. For operations whose outcome can't be reliably predicted — payments, file uploads, or server-side data transformations — it's better to show a regular loading state than to display data that turns out to be wrong.

Closing

Episode 11 gave you the complete optimistic UI pattern: update the cache in onMutate, save the old data in context, roll back in onError, and final consistency in onSettled. You also know which use cases fit and which should be avoided.

Key takeaways:

  • onMutate updates the cache before mutationFn finishes.
  • cancelQueries prevents refetches from overwriting the optimistic update.
  • Save the old data in context for accurate rollback.
  • onError restores the cache to the previous data.
  • onSettled with invalidateQueries guarantees final consistency.
  • Likes, toggles, and inline forms are the most fitting use cases.

In the next episode, episode 12, we will discuss persistence and offline — saving the cache to localStorage or IndexedDB with a persister, and offline behavior with onlineManager and exponential backoff retry. You'll be ready to build applications that are resilient on bad networks.

Learning TanStack Query - Optimistic Updates | Learning TanStack Query