Back to Blog
Lesson 21 of the Advanced React: Performance, Architecture & Patterns course
ReactJune 28, 20263 min read

Mastering Optimistic UI Updates in React for Snappy UX

Learn how to implement Optimistic UI patterns in React. Master optimistic rollback logic, server-client synchronization, and failure handling for elite UX.

ReactUXPerformanceData FetchingMutationsjavascriptfrontend

Previously in this course, we explored Mastering Suspense for Data Fetching: A Declarative Approach to handle loading states. While loading skeletons improve perceived performance, they don't solve the "latency gap" in user interactions. Today, we bridge that gap with Optimistic UI.

Optimistic UI is the practice of updating the application state immediately upon user interaction, assuming the server will succeed, while keeping the "real" state in the background. It transforms the user experience from "click, wait, see result" to "click, see result instantly."

The Mechanics of Optimistic UI

At its core, an optimistic update follows a three-step lifecycle:

  1. Snapshot: Capture the current state before the mutation.
  2. Optimistic Update: Apply the change to the UI immediately.
  3. Reconciliation: If the server succeeds, sync the cache; if it fails, roll back to the original snapshot.

Implementation Pattern

We typically implement this using a mutation library like React Query (TanStack Query), which simplifies the orchestration of these steps. If you haven't mastered the basics yet, review Mastering Mutations and Data Updates with React Query.

Here is a concrete example of an optimistic "Like" button in our project:

TSX
const useLikePost = (postId: string) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newLikeStatus: boolean) => api.updateLike(postId, newLikeStatus),
    
    // 1. Snapshot: Cancel outgoing fetches so they don't overwrite our optimistic update
    onMutate: async (newLikeStatus) => {
      await queryClient.cancelQueries({ queryKey: [CE9178">'post', postId] });

      const previousPost = queryClient.getQueryData([CE9178">'post', postId]);

      // 2. Optimistic Update: Manually update the cache
      queryClient.setQueryData([CE9178">'post', postId], (old: any) => ({
        ...old,
        liked: newLikeStatus,
      }));

      // Return context for rollback
      return { previousPost };
    },

    // 3. Failure Handling: Roll back on error
    onError: (err, newLikeStatus, context) => {
      queryClient.setQueryData([CE9178">'post', postId], context.previousPost);
    },

    // Always refetch after error or success to ensure synchronization
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: [CE9178">'post', postId] });
    },
  });
};

Managing Synchronization and Failure

The most common mistake is failing to handle the "rollback" or the "final state." If you update the UI optimistically but the server returns an error, failing to revert the state leaves the user in an inconsistent, confusing reality.

Handling Failure States

When the server request fails, you must revert the UI to the exact state it was in before the interaction. The context object returned in onMutate is your safety net. Always store the previous state there.

ActionUI StateServer State
InitialSyncedSynced
MutationOptimisticPending
SuccessSyncedSynced
FailureRollbackReverted

Hands-on Exercise

In our project, we have a list of task items. Currently, toggling a task completion shows a loading spinner for 500ms.

Your task:

  1. Refactor the toggleTask mutation to be optimistic.
  2. Ensure you call queryClient.cancelQueries to prevent the "flicker" caused by a late-arriving background fetch.
  3. Add a toast notification in onError to inform the user that the change could not be saved, effectively communicating the rollback.

Common Pitfalls

  1. Ignoring Race Conditions: If a user clicks a button multiple times quickly, multiple mutations might fire. Always cancel existing queries using queryClient.cancelQueries before performing the optimistic update.
  2. Partial Updates: Ensure that your setQueryData logic is immutable. Don't just update the one field; ensure the entire object structure is maintained correctly.
  3. Silent Failures: Never fail silently. If an optimistic update is rolled back, the user must be notified, or they will assume the system is broken when their action "undoes" itself.

Recap

Optimistic UI is a powerful tool for perceived performance, but it requires rigorous state management. By capturing snapshots, performing atomic cache updates, and implementing robust error rollback handlers, you can create interfaces that feel instantaneous while maintaining absolute data integrity.

Up next, we will tackle Advanced Cache Invalidation, where we'll learn how to keep fragmented data stores in sync across complex component trees.

Similar Posts