Optimistic Updates Beyond useOptimistic: Rollback with TanStack Query
AI generated
</>
{ }
React · TanStack Query · State Management
Optimistic Updates beyond useOptimistic
rollback strategies with TanStack Query

React's useOptimistic hook elegantly solves a single, local use case, but falls short on nested lists, parallel mutations and race conditions across multiple components. This article shows how Optimistic Updates become robust in real applications with TanStack Query mutations, snapshot based rollback and explicit query cancellation.

18 min read onMutate · Snapshot Rollback · Race Conditions · Undo Pattern React 19 · TanStack Query v5

1. Why useOptimistic alone is not enough

Optimistic Updates solve a simple problem: instead of waiting for the server response, the UI updates immediately as soon as a user triggers an action, and only corrects itself if the server actually reports an error. React's useOptimistic hook covers the simplest case of this idea: a single state value, a single action, a single success path. For a like icon or a form field, this is entirely sufficient.

As soon as several list items need updating at once, several mutations run in parallel, or the updated data must be visible in several components simultaneously, useOptimistic hits its limits. The hook has no notion of a global cache, no query invalidation and no built in concept for race conditions between two overlapping Optimistic Updates. This article shows how TanStack Query closes this gap with onMutate, snapshot rollback and explicit query cancellation.

2. useOptimistic recapped: the limits of the hook

useOptimistic takes a current state value and a reducer function and returns a derived, optimistic value that updates immediately once a transition starts with the new value. As long as the enclosing action runs, the component shows the optimistic value. If the action fails, React automatically snaps back to the previous value once the transition ends. This is elegant for local, component scoped state.

The problem arises once the same record is needed in several places in the tree, for example a todo list and a separate dashboard with an open todo counter. useOptimistic has no shared cache: every component would have to manage the optimistic state independently, which leads to inconsistencies as soon as two components make different assumptions about the current state. For Optimistic Updates that need to stay consistent across component boundaries, a central cache such as TanStack Query's or Apollo Client's is the more robust choice.


// TodoItem.tsx — useOptimistic works well for a single, local value
import { useOptimistic, startTransition } from "react";

function TodoItem({ todo, toggleTodo }) {
  const [optimisticTodo, setOptimisticTodo] = useOptimistic(
    todo,
    (state, done) => ({ ...state, done })
  );

  function handleToggle() {
    startTransition(async () => {
      setOptimisticTodo(!optimisticTodo.done);
      await toggleTodo(todo.id); // if this throws, React reverts automatically
    });
  }

  // Limitation: a sibling counter component has no access to this
  // optimistic state, it only sees the real state until the mutation settles.
  return <button onClick={handleToggle}>{optimisticTodo.done ? "Done" : "Open"}</button>;
}

3. TanStack Query mutations: onMutate, snapshot and rollback

TanStack Query solves exactly this problem, because Optimistic Updates happen directly in the central query cache that all components read through useQuery. The onMutate callback of a mutation runs before the network request is even sent, and can update the cache directly with queryClient.setQueryData. Every component reading that query sees the change immediately, regardless of where it sits in the tree.

The decisive step for robustness is the snapshot pattern: before the cache is changed, the current state is saved with queryClient.getQueryData and returned from onMutate. If the mutation fails, onError restores this exact snapshot. Without this step, a failed Optimistic Update stays permanently in the cache and the UI shows a state that was never actually confirmed.


// useToggleTodo.ts — TanStack Query mutation with snapshot-based rollback
import { useMutation, useQueryClient } from "@tanstack/react-query";

function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => api.toggleTodo(id),

    onMutate: async (id) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      const previousTodos = queryClient.getQueryData(["todos"]);

      queryClient.setQueryData(["todos"], (old) =>
        old.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
      );

      // Returned here, available as context in onError
      return { previousTodos };
    },

    onError: (_err, _id, context) => {
      // Roll back to the exact snapshot taken before the optimistic update
      queryClient.setQueryData(["todos"], context.previousTodos);
    },

    onSettled: () => {
      // Always resync with the server, whether it succeeded or failed
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

4. Race conditions with parallel Optimistic Updates

A subtle problem arises when a user clicks something several times in quick succession, for example a like icon, while the previous mutation is still running. Without protection, a background refetch of the old query data can overwrite the just applied Optimistic Update the moment the network response arrives, before the mutation itself has completed. The result is a brief, confusing snap back of the UI that users perceive as a bug, even though the final state is eventually correct.

queryClient.cancelQueries in onMutate is the central protection mechanism against this problem. The call cancels every currently running refetch for the affected query key before the optimistic value is set, so no stale refetch can overwrite the Optimistic Update. For mutations that can be triggered quickly several times, such as a counter with plus and minus buttons, it is also advisable to compute the optimistic value relatively rather than absolutely, so several fast clicks add up correctly instead of overwriting each other.

5. Nested Optimistic Updates: lists and counters

With a list and a derived counter, for example a todo list with a display of open items, an Optimistic Update must update both values consistently. If only the list, but not the counter, is updated optimistically, the UI briefly shows conflicting information. The robust solution is to compute the counter as a derived value directly from the list, instead of maintaining it as its own query entry, so a single setQueryData call on the list automatically keeps the counter correct too.

For deeply nested structures, such as comments under a post with their own counter fields, it is worth writing a small helper function that centrally encapsulates the update path, instead of repeating the nesting logic in every onMutate function. This encapsulation considerably reduces error proneness, because changes to the data structure only need to be made in one place, instead of in every single mutation that performs Optimistic Updates on the same nested data.


// useAddComment.ts — Optimistic update on a nested list with a derived counter
function useAddComment(postId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (text: string) => api.addComment(postId, text),

    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ["post", postId] });
      const previousPost = queryClient.getQueryData(["post", postId]);

      queryClient.setQueryData(["post", postId], (old) => ({
        ...old,
        comments: [...old.comments, { id: `temp-${Date.now()}`, text, pending: true }],
        // commentCount is derived here, so it never drifts from the list length
        commentCount: old.comments.length + 1,
      }));

      return { previousPost };
    },

    onError: (_err, _text, context) => {
      queryClient.setQueryData(["post", postId], context.previousPost);
    },
  });
}

6. Error handling: toast, retry and undo pattern

A plain rollback without user feedback leaves users unsure why their action suddenly disappeared. In addition to the snapshot rollback in onError, robust Optimistic Updates always need a visible error message, usually through a toast that explains the action failed, combined with a retry option. Without this feedback, the brief flash and disappearance of the change looks like a rendering bug in the application.

Another proven pattern is undo instead of an immediate server request: an action such as deleting an email is applied optimistically in the UI immediately, but the actual server request is delayed by a few seconds while an undo button is shown. If the user clicks undo, the server request is never triggered and the Optimistic Update is simply reverted. This pattern reduces actual server requests and gives users a deliberate way to correct themselves, instead of relying on error handling after the fact.

7. Optimistic Updates with Apollo Client compared

Apollo Client pursues a similar principle with optimisticResponse, but more deeply integrated into the normalized GraphQL cache. Instead of writing an onMutate function manually, you declare directly at the mutation call site what the expected optimistic response looks like, and Apollo automatically handles the cache update and rollback on failure. For teams already working with Apollo, this declarative approach is often less code than TanStack Query's manual snapshot pattern.

The downside of Apollo's approach is that it requires GraphQL and a normalized cache structure, while TanStack Query works with any data source, REST, GraphQL or direct function calls. For Optimistic Updates on REST endpoints, TanStack Query remains the more practical choice, because it has no GraphQL layer as a prerequisite and the snapshot logic sits explicit and traceable in your own code.

8. Testing Optimistic Updates with MSW and Vitest

Optimistic Updates are especially test relevant because the interesting case is not the success path, but the correct rollback on failure. With Mock Service Worker, an endpoint can be configured to return an error on purpose, and the test checks whether the UI returns to exactly the previous state after the failed Optimistic Update, instead of getting stuck in an inconsistent intermediate state.

A second important test case is the race condition situation from section four: two mutations fired in quick succession should produce the same final state regardless of the order in which the network responses arrive. Tests with artificially delayed MSW handlers uncover bugs that are practically never visible in manual testing over a fast local connection, but occur regularly in production with variable network latency.


// toggleTodo.test.tsx — Verifying rollback after a failed optimistic update
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";

test("rolls back optimistic update on server error", async () => {
  server.use(
    http.patch("/api/todos/:id", () => HttpResponse.json({ error: "failed" }, { status: 500 }))
  );

  const { result } = renderHook(() => useToggleTodo(), { wrapper: createWrapper() });

  result.current.mutate("todo-1");

  // Optimistic value appears immediately
  expect(getCachedTodo("todo-1").done).toBe(true);

  // After the failed request, the cache must match the pre-mutation snapshot
  await waitFor(() => expect(getCachedTodo("todo-1").done).toBe(false));
});

9. Optimistic update strategies compared directly

The following table compares the three approaches to Optimistic Updates covered here, focusing on reach across the component tree and rollback behavior.

Approach Reach Rollback Race condition protection
useOptimistic Local component only Automatic, but local None built in
TanStack Query Global cache, all components Manual via snapshot in onError cancelQueries
Apollo Client Global normalized cache Automatic Built in, GraphQL specific

For single, component local interactions, useOptimistic remains the simplest solution with no additional dependency. As soon as the same record must be visible in several components, or race conditions on fast interactions pose a real risk, TanStack Query and Apollo Client deliver the more robust tools for production ready Optimistic Updates.

Mironsoft

React state management and production ready optimistic update patterns

A UI that responds instantly and stays consistent?

We build snapshot based rollback strategies, race condition protection and undo patterns for your TanStack Query or Apollo Client mutations.

Mutation audit

Analysis of existing mutations for missing rollback and race conditions

Undo pattern

Delayed server requests with an undo button for critical actions

Test suite

MSW and Vitest tests for rollback and race condition scenarios

10. Summary

Optimistic Updates with useOptimistic elegantly solve the simple, local case, but hit limits as soon as the same record must be visible in several components. TanStack Query solves this problem through the global query cache with onMutate, snapshot rollback in onError, and cancelQueries as protection against race conditions. Nested structures such as lists with derived counters benefit from computing the counter directly from the list data instead of maintaining it separately.

Apollo Client's optimisticResponse offers a more declarative path for GraphQL based applications with automatic rollback. For user feedback, visible error messages and, where sensible, undo patterns belong to robust Optimistic Updates. Tests with MSW that deliberately simulate errors make sure rollback and race condition protection actually work, before they surprise anyone in production.

Optimistic Updates beyond useOptimistic, the essentials at a glance

Limits of useOptimistic

Local component state only, no shared cache between components.

Snapshot rollback

Save state before the change in onMutate, restore it exactly in onError.

Race conditions

cancelQueries before every optimistic update prevents overwriting refetches.

User feedback

Toast on failure, undo pattern for critical, delayable actions.

11. FAQ: Optimistic Updates beyond useOptimistic

1When is useOptimistic no longer enough?
As soon as the same record must be visible in several components, useOptimistic has no shared cache.
2What does onMutate do exactly?
Runs before the network request, saves the state and sets the optimistic value via setQueryData.
3Why a snapshot instead of a simple reset?
Without an exact snapshot, it is unclear which value was the correct starting state.
4What does cancelQueries prevent?
Cancels ongoing refetches so a stale response cannot overwrite the optimistic update.
5How to update a derived counter optimistically?
Most robustly by computing it directly from the list length, instead of a separate query entry.
6onMutate vs. optimisticResponse?
onMutate is manual and explicit, optimisticResponse is declarative and tied to GraphQL.
7Should I always use an undo pattern?
Only when a short delay is acceptable, like deleting. Otherwise, snapshot rollback remains correct.
8How do I test a rollback?
Simulate an error with MSW and check the cache matches exactly the state before the mutation.
9What happens without onSettled?
The cache stays in the optimistic state, never reconciled with the actual server result.
10Does every mutation need an Optimistic Update?
No, rare background actions are fine with a normal refetch. Pays off most for frequent interactions.