How to Implement Optimistic UI Updates in React with TanStack Query

How to Implement Optimistic UI Updates in React with TanStack Query

by | Aug 7, 2026 | Uncategorized | 0 comments

Users expect instant feedback. When they click a button, they don’t want to wait 400ms for a server response before seeing something happen on screen. That’s exactly where optimistic UI with React Query (TanStack Query) shines. In this practical tutorial, we’ll build a to-do list app that updates instantly, handles rollbacks gracefully, and never leaves the user staring at a spinner.

By the end of this post, you’ll know how to use TanStack Query’s mutation hooks to implement optimistic updates the right way, including how to handle failures, race conditions, and cache invalidation.

What is Optimistic UI (and Why It Matters)

Optimistic UI is a pattern where your interface updates immediately, assuming the server request will succeed. If it does, great: the user got instant feedback. If it fails, you roll back the change and show an error. It’s about perceived performance, not actual performance.

Compared to pessimistic updates (waiting for the server before updating the UI), optimistic updates make your app feel snappier, especially on slow networks.

Approach User Experience Complexity Best For
Pessimistic Slower, safer feel Low Financial, critical ops
Optimistic Instant, responsive Medium Likes, todos, toggles
react code laptop

The Two Ways React Query Supports Optimistic Updates

TanStack Query gives you two official patterns to implement optimistic UI:

  1. Via the UI (using mutation state): read variables from useMutation and render them directly in your list. No cache manipulation needed.
  2. Via the cache (using onMutate): manually update the query cache before the mutation resolves, then roll back on error.

The cache approach is more powerful and flexible. It’s what we’ll focus on for most of this tutorial.

Setting Up the To-Do App

Let’s assume you already have TanStack Query v5 installed. Here’s the base query for fetching todos:

import { useQuery } from '@tanstack/react-query';

function useTodos() {
  return useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const res = await fetch('/api/todos');
      return res.json();
    },
  });
}

Simple enough. Now let’s add a mutation to create a new todo optimistically.

Implementing Optimistic Updates with onMutate

The onMutate callback fires before your mutation function runs. This is where you snapshot the current cache and apply the optimistic change.

import { useMutation, useQueryClient } from '@tanstack/react-query';

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

  return useMutation({
    mutationFn: async (newTodo) => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      });
      if (!res.ok) throw new Error('Failed');
      return res.json();
    },

    // 1. Fires before the mutation
    onMutate: async (newTodo) => {
      // Cancel any outgoing refetches to avoid overwriting our optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot the previous value
      const previousTodos = queryClient.getQueryData(['todos']);

      // Optimistically update the cache
      queryClient.setQueryData(['todos'], (old = []) => [
        ...old,
        { ...newTodo, id: Date.now(), pending: true },
      ]);

      // Return context so we can roll back later
      return { previousTodos };
    },

    // 2. If it fails, roll back
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },

    // 3. Always refetch after success or error
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Let’s break down what’s happening step by step.

Step 1: Cancel Ongoing Queries

queryClient.cancelQueries is critical. Without it, a background refetch could resolve after your optimistic update and overwrite it with stale server data.

Step 2: Snapshot the Previous State

Save the current cache value. If the mutation fails, this is what you’ll restore.

Step 3: Apply the Optimistic Update

Use setQueryData to update the cache immediately. The UI will re-render right away because React Query notifies all subscribers.

Step 4: Roll Back on Error

In onError, restore the snapshot you saved. The user sees the failed change reverted.

Step 5: Sync With the Server

In onSettled, invalidate the query so React Query refetches fresh data from the server. This ensures your cache eventually matches reality.

react code laptop

Optimistic Updates for Toggling and Deleting Todos

The same pattern works for updates and deletes. Here’s a toggle example:

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

  return useMutation({
    mutationFn: async (id) => {
      const res = await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' });
      if (!res.ok) throw new Error('Toggle failed');
      return res.json();
    },
    onMutate: async (id) => {
      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))
      );

      return { previousTodos };
    },
    onError: (_err, _id, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

The Simpler Approach: Optimistic UI Without Touching the Cache

Since TanStack Query v5, you can implement optimistic UI directly from the mutation state, without manually updating the cache. This is perfect for simple cases like adding items to a list.

function TodoList() {
  const { data: todos = [] } = useTodos();
  const addTodo = useAddTodo();

  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}

      {addTodo.isPending && (
        <li style={{ opacity: 0.5 }}>{addTodo.variables.title}</li>
      )}

      {addTodo.isError && (
        <li style={{ color: 'red' }}>
          {addTodo.variables.title} (failed)
          <button onClick={() => addTodo.mutate(addTodo.variables)}>Retry</button>
        </li>
      )}
    </ul>
  );
}

This approach is simpler, less error prone, and avoids race conditions. Use it when you don’t need the optimistic item to appear in every component reading the same query.

Choosing Between Cache Updates and UI State

Scenario Recommended Approach
Adding an item to a single list UI state (mutation variables)
Toggling or editing an existing item Cache update with onMutate
Multiple components read the same query Cache update
Concurrent mutations on the same list UI state (safer)
react code laptop

Handling Concurrent Mutations

A common pitfall: if two optimistic mutations run at the same time, the second one’s snapshot in onMutate may include the first’s optimistic change. If the first fails, rolling back will also undo the second.

Ways to mitigate this:

  • Prefer the UI state approach when you have rapid concurrent mutations.
  • Use unique client generated IDs (like crypto.randomUUID()) so the server response can reconcile properly.
  • Avoid rolling back the full list. Roll back only the specific item that failed.

Best Practices for Optimistic UI in React Query

  1. Always call cancelQueries before applying the optimistic update.
  2. Always snapshot and return the previous state in onMutate.
  3. Always invalidate in onSettled so the cache eventually matches the server.
  4. Show visual feedback (opacity, spinner, badge) for pending items so users know something is in flight.
  5. Provide a retry mechanism for failed mutations rather than silently rolling back.
  6. Don’t use optimistic UI for critical operations like payments or irreversible destructive actions.

Common Pitfalls to Avoid

  • Forgetting cancelQueries, which lets a background refetch stomp your optimistic update.
  • Not returning the snapshot from onMutate, so context is undefined in onError.
  • Invalidating queries too early (in onSuccess) can cause the optimistic item to flash out then back in.
  • Using unstable IDs (like array index) that cause React to re-render everything on reconciliation.

Frequently Asked Questions

How do I implement optimistic UI in React?

Use TanStack Query’s useMutation hook with onMutate to update the cache immediately, then onError to roll back and onSettled to refetch. For simpler cases, read variables and isPending directly from the mutation.

Is React Query still relevant in 2026?

Yes. TanStack Query remains one of the most popular data fetching and caching libraries for React, and it works alongside modern patterns like React Server Components and the built in useOptimistic hook.

What is the difference between useOptimistic and React Query’s optimistic updates?

React’s built in useOptimistic hook manages local optimistic state during a transition. React Query’s approach integrates with the global cache, so multiple components stay in sync. You can combine both for advanced use cases.

Should I always use optimistic updates?

No. Use them for actions that are likely to succeed and where instant feedback matters (likes, toggles, adding items). Avoid them for critical operations where showing false success would confuse or mislead users.

How do I handle rollback when multiple mutations run at once?

Track each mutation’s state individually rather than snapshotting the whole list. The UI state approach (reading from mutation.variables) handles this naturally.

Wrapping Up

Optimistic UI with React Query is one of the highest impact changes you can make to your app’s perceived performance. With onMutate, onError, and onSettled, you have all the tools needed to build a snappy, resilient interface that gracefully handles failure.

Start small: pick one mutation in your app (a toggle, a like button, a status change) and add optimistic behavior. Your users will feel the difference immediately.

Need help implementing performant React architectures? The team at Box Software builds fast, scalable frontends every day. Get in touch to discuss your project.