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:
We follow the optimistic execution flow:
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:
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"] });
},
});
}