← Back to blog

Fake It ’Til You Make It: Mastering Optimistic Updates with TanStack Query v5

Fake It ’Til You Make It: Mastering Optimistic Updates with TanStack Query v5
Zahid Ul Islam
Zahid Ul Islam

Nobody likes waiting for a loading spinner just to "like" a post or check off a todo item. Learn how to make your React applications feel lightning-fast by implementing optimistic UI updates using the powerful lifecycle hooks in the latest TanStack Query v5.

Introduction

In modern web development, user experience is paramount. We want our applications to feel "app-like"—snappy, instant, and responsive.

Traditionally, when a user performs an action that changes data (like clicking a "Like" button), the flow looks like this:

  1. The user clicks the button.
  2. Show a loading spinner next to the button.
  3. Send an API request to the server.
  4. Wait for the server to respond with "OK".
  5. Hide the spinner and update the UI with the new "liked" state.

That gap between steps 2 and 5, even if it's only 300ms, creates friction. It makes the app feel sluggish.

Optimistic UI flips this script. Instead of waiting for the server, we assume the request will succeed and update the UI immediately. We then send the request in the background. If it succeeds, great; the user never knew the difference. If it fails, we quietly roll the UI back to its previous state.

TanStack Query (React Query) v5 provides the perfect toolkit to manage this complex state dance flawlessly.

The Scenario: A Todo List

Let's imagine a simple Todo list application. We have a list of items, and we want to toggle the "completed" status of one item.

We already have a query fetching the list:

Javascript
const { data: todos } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

Now we need a mutation to handle the toggle.

The "Standard" (Slow) Mutation

A typical mutation without optimistic updates looks like this. It waits for the server to finish before the user sees changes.

Javascript
const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (todoId) => toggleTodoApi(todoId),
  onSuccess: () => {
    // Wait for server success, then refetch the list
    // so the UI reflects the true server state.
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

This works, but it's slow. Let's make it optimistic.

Implementing the Optimistic Update

To achieve an optimistic update in TanStack Query, we utilize the lifecycle callbacks of useMutation:

  1. onMutate: Fires immediately when the mutation starts, before the network request goes out. This is where we update the UI optimistically.
  2. onError: Fires if the network request fails. This is where we roll back the UI.
  3. onSettled: Fires regardless of success or failure. This is where we sync with the server to ensure our data is definitively correct.

Here is the full implementation pattern:

The Code

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

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

  return useMutation({
    // 1. The actual network request
    mutationFn: (todoId) => toggleTodoApi(todoId),

    // 2. onMutate fires before the mutationFn runs
    onMutate: async (todoIdToToggle) => {
      // Stop any outgoing refetches automatically
      // so they don't overwrite our optimistic update immediately.
      await queryClient.cancelQueries({ queryKey: ['todos'] });

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

      // Optimistically update to the new value
      // We use functional state updates to ensure we are working with the latest data
      queryClient.setQueryData(['todos'], (oldTodos) => {
        if (!oldTodos) return;
        
        return oldTodos.map(todo => 
          todo.id === todoIdToToggle
            ? { ...todo, completed: !todo.completed } // toggle the target
            : todo
        );
      });

      // Return a context object with the snapshotted value.
      // This will be passed to onError.
      return { previousTodos };
    },

    // 3. If the mutation fails, use the context returned from onMutate to roll back
    onError: (err, newTodo, context) => {
      // context.previousTodos contains the data we saved in onMutate
      if (context?.previousTodos) {
         queryClient.setQueryData(['todos'], context.previousTodos);
      }
    },

    // 4. Always refetch after error or success
    onSettled: () => {
      // This ensures that even if our optimistic guess was slightly wrong, 
      // or if other things changed on the server, the UI eventually reflects reality.
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Breakdown of Key Steps

1. Canceling Queries (onMutate)

await queryClient.cancelQueries({ queryKey: ['todos'] });

This is a crucial, often overlooked step. Imagine the user clicks "toggle," we optimistically update the UI, but a background refetch of the old data happens to finish 50ms later. The UI would flicker back to the old state, then eventually to the new state. Canceling outgoing queries prevents this race condition.

2. The Snapshot (onMutate)

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

Before we mess with the cache, we need to save exactly what it looked like. If the server rejects our change, this is the backup we will restore.

3. The Optimistic Set (onMutate)

queryClient.setQueryData(...)

We manually manipulate the React Query cache. We find the item that was clicked and flip its completed status instantly. The React components listening to the ['todos'] query will re-render immediately.

4. The Rollback (onError)

onError: (err, variables, context)

If the API call fails, TanStack Query hands us the context object we returned from onMutate. We take the snapshot (context.previousTodos) and jam it back into the cache using setQueryData. The user sees the UI revert to its original state.

5. The Final Sync (onSettled)

queryClient.invalidateQueries({ queryKey: ['todos'] });

Whether the optimistic update succeeded or failed, it's best practice to trigger a fresh fetch from the server. This ensures ultimate consistency and catches any side effects the server might have performed that our optimistic update didn't predict.

Conclusion

Optimistic updates are a bit more code upfront, but the payoff in user experience is immense. By using TanStack Query's lifecycle hooks, you can implement complex state management patterns—including cancellation, snapshots, and rollbacks—in a robust and reusable way. Give your users the instant feedback they crave.

Share this post on: