logologo
Articles
#UX#TanStack Query#Next.js#React

Why Optimistic UI is Essential for Modern Web Applications

The Gap Between Web Apps and Instant Native Experiences

Consider this comparison: You are using an app like Twitter or Slack. You hit the "Like" button or move a task card across a board. The action reflects in the exact same microsecond—no spinners, no frozen states, no friction.

Now switch to a traditional web app: You click a button, a loading spinner spins for two seconds, the UI locks up slightly, and finally, the state updates.

This fundamental difference in perception—between an application that feels like a desktop-native software and a sluggish web interface—defines product success today. Modern users lack the patience to wait for Network Latency on every single micro-interaction.

The Solution? Being preemptively optimistic via Optimistic UI Updates.

How Optimistic UI Works

The concept relies on a simple engineering premise: 99% of valid user-initiated requests succeed.

Instead of the traditional imperative flow:

Send Request
Wait
Receive Response
Update UI

We follow the optimistic execution flow:

Mutate UI Instantly
Dispatch Server Request in Background
Confirm (or Rollback on Error)

Practical Implementation with TanStack Query

TanStack Query (React Query) provides a robust framework to implement optimistic mutations safely. The key lies in leveraging the onMutate handler to mutate the local cache while creating a rollback snapshot.

Here is a practical hook for updating task statuses in a Kanban environment:


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

interface Task {
id: string;
title: string;
status: "todo" | "in-progress" | "done";
}

export function useUpdateTaskStatus() {
  const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({
taskId,
newStatus,
}: {
taskId: string;
newStatus: Task["status"];
}) => {
const response = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});

      if (!response.ok) throw new Error("Failed to update task status");
      return response.json();
    },

    // 1. Executes immediately before the network request
    onMutate: async ({ taskId, newStatus }) => {
      // Cancel outgoing queries to avoid overwriting our optimistic update
      await queryClient.cancelQueries({ queryKey: ["tasks"] });

      // Snapshot the previous state for rollback
      const previousTasks = queryClient.getQueryData<Task[]>(["tasks"]);

      // Optimistically update the cache
      queryClient.setQueryData<Task[]>(["tasks"], (old) =>
        old?.map((task) =>
          task.id === taskId ? { ...task, status: newStatus } : task,
        ),
      );

      // Return context containing the rollback snapshot
      return { previousTasks };
    },

    // 2. Executes if the server request fails
    onError: (_err, _variables, context) => {
      if (context?.previousTasks) {
        // Rollback cache to the exact snapshot state
        queryClient.setQueryData(["tasks"], context.previousTasks);
      }
    },

    // 3. Always refetch after error or success to enforce consistency
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["tasks"] });
    },

});
}

End of the article