React|Server State and Async Data

useMutation and Optimistic Updates: Don't Let the User Feel Even 0.1s of Delay

1
useMutation and Optimistic Updates: Don't Let the User Feel Even 0.1s of Delay

Let's try clicking the 'Like' button in the SNS app Chris built.

When clicked, a loading spinner spins for 0.5 seconds, and then the heart turns red. Functionally, it's perfect. But it lacks that "snappy" feel you get when using Instagram or Twitter.

Users might tolerate their own slow internet, but they cannot tolerate a slow app response. Waiting for the server to respond is too honest and slow.

Today, we will implement Optimistic Updates, a bold technique where we assume success and update the screen immediately, even before the server responds.

1. Honest Updates vs. Optimistic Updates

The typical mutation flow looks like this:

  • Request: User clicks button -> Request sent to server.
  • Wait: Show loading indicator while server updates DB.
  • Reflect: Update UI when success response arrives.
  • The 0.2~0.5 second gap here determines the User Experience (UX).

    Optimistic Updates flip this order:

  • Reflect: User clicks button -> Update UI immediately (Heart pops!).
  • Request: Send request to server in the background.
  • Confirm: Keep if successful, quietly Rollback if failed.
  • 2. Implementing with TanStack Query

    TanStack Query's useMutation provides the perfect Lifecycle Hooks for these optimistic updates.

    The Core 3-Step Logic

  • onMutate (Right before execution): Runs as soon as the request leaves. Cancel ongoing queries, backup (Snapshot) the current data, and manipulate the cache to change the screen.
  • onError (On failure): Revert the cache using the backed-up data.
  • onSettled (On completion): Whether successful or failed, Refetch the latest data to ensure synchronization with the server.
  • Implementation in Code

    Let's look at the 'Like' feature Chris wrote.

    // useLikeTodo.ts
    import { useMutation, useQueryClient } from '@tanstack/react-query';
    
    export function useLikeTodo() {
      const queryClient = useQueryClient();
    
      return useMutation({
        mutationFn: (todoId: string) => api.likeTodo(todoId),
    
        // 1. Runs the moment the mutation starts
        onMutate: async (todoId) => {
          // Cancel ongoing queries to prevent overwrites
          await queryClient.cancelQueries({ queryKey: ['todos'] });
    
          // Save previous state for rollback (Snapshot)
          const previousTodos = queryClient.getQueryData(['todos']);
    
          // Forcefully modify the cache (Don't wait for server response!)
          queryClient.setQueryData(['todos'], (old: Todo[]) => {
            return old.map(todo => 
              todo.id === todoId ? { ...todo, liked: true } : todo
            );
          });
    
          // Return snapshot (Used in onError)
          return { previousTodos };
        },
    
        // 2. Runs if an error occurs
        onError: (err, newTodo, context) => {
          // Rollback to snapshot
          if (context?.previousTodos) {
            queryClient.setQueryData(['todos'], context.previousTodos);
          }
          alert("Failed to like! Please try again later.");
        },
    
        // 3. Runs when finished, regardless of success or failure
        onSettled: () => {
          // Refetch to ensure data consistency with server
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
      });
    }

    3. Handling UX Details

    Optimistic updates are powerful, but they shouldn't be abused.

    When to use?

  • Actions with low failure probability: Likes, Bookmarks, Todo list checks, Drag and Drop.
  • Actions where immediate feedback is crucial: Sending chat messages.
  • When NOT to use?

  • Actions where the result is critical: Payments, Sign-ups, Data deletion. (It is better to show a spinner because failure here is a big deal.)
  • Handling Failure Scenarios

    If an optimistic update fails, the user gets confused because the red heart suddenly turns empty again. Therefore, it is essential to kindly inform them via a Toast message in onError saying, "The action was not applied."

    Key Takeaways

  • Optimistic Update: A technique that updates the UI assuming success without waiting for the server response. It makes the app feel much faster and snappier.
  • onMutate: Cancel queries here (cancelQueries) and manipulate the cache (setQueryData). You must return the previous data to back it up.
  • onError: Use the backed-up data (context) to restore the cache.
  • onSettled: Use invalidateQueries to fetch the latest data for data consistency.

  • Now that we've gifted the user a snappy experience, we need to think about how to handle massive amounts of data. How do we implement a feed where you scroll endlessly?

    Continuing in: “useInfiniteQuery and Intersection Observer: The Standard for Infinite Scroll”

    🔗 References

  • TanStack Query - Optimistic Updates
  • Mastering Mutations in React Query