What is an Optimistic UI Update?

Normally, when a user performs an action (like posting a comment), the flow is:

  1. User clicks "Submit".
  2. Show a loading spinner.
  3. Wait for the server to respond with "Success".
  4. Remove the spinner and show the new comment in the list.

This wait, even if it's just a few hundred milliseconds, can make an application feel sluggish.

An optimistic update flips this on its head. It assumes the server request will succeed and updates the UI immediately, before the server has even responded.

Analogy: When you send a message in a chat app, it appears in your chat window instantly, often with a small "sending..." icon. The app is optimistically assuming the message will go through. If it fails, it then shows an error.

The Optimistic Update Flow

  1. User performs an action.
  2. Immediately update the client-side state/cache with the expected new state. The UI re-renders instantly.
  3. Send the actual API request to the server in the background.
  4. On Success: The server confirms the change. The client state is already correct, so we might just need to refetch the data to get the final, server-confirmed version (e.g., an item with a real ID).
  5. On Error: The server returns an error. We must rollback the UI change and show an error message to the user.

Implementing Optimistic Updates with TanStack Query

TanStack Query's useMutation hook provides the perfect lifecycle callbacks to implement this pattern cleanly.

  • onMutate: This function runs before the mutation function. It's the ideal place to optimistically update our cache. It should return a context object (e.g., a snapshot of the previous state) that can be used for rollbacks.
  • onError: This function runs if the mutation fails. It receives the context from onMutate, allowing us to rollback the UI to its previous state.
  • onSettled: This function runs after the mutation either succeeds or fails. It's the perfect place to refetch the real data to ensure our UI is in sync with the server.

Code Example (Adding a Todo):

JavaScript


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

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

  const mutation = useMutation({
    mutationFn: (newTodo) => axios.post('/api/todos', newTodo),

    // 1. Runs before the mutation
    onMutate: async (newTodo) => {
      // Cancel any outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] });

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

      // Optimistically update to the new value
      queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

      // Return a context object with the snapshotted value
      return { previousTodos };
    },

    // 2. If the mutation fails, use the context returned from onMutate to roll back
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
      // Show an error toast or message
    },

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

  const handleAddTodo = () => {
    // We add a temporary ID for the optimistic update
    const tempTodo = { id: Date.now(), title: 'New optimistic todo', isTemporary: true };
    mutation.mutate(tempTodo);
  };

  return <button onClick={handleAddTodo}>Add Todo</button>;
}