React Optimistic Updates in 2026: React Query Mutations, useOptimistic, and Rollback
Build React optimistic updates with React Query mutations and useOptimistic: instant UI feedback, rollback on failure, conflict resolution, and patterns for list mutations and form submissions.
React Optimistic Updates in 2026: React Query Mutations, useOptimistic, and Rollback
Optimistic updates make your UI feel instant. Instead of waiting for the server to confirm a mutation, you update the UI immediately as if the operation succeeded, then reconcile with the server response. If the server fails, you roll back to the previous state. When done right, users never see a spinner for common operations like toggling a task complete or liking a post.
This post covers both approaches available in 2026: React Query's useMutation with onMutate/onError/onSettled for cached data, and React 19's useOptimistic hook for Server Action flows. We cover list mutations, form submissions, and the edge cases around rollback and conflict resolution.
The Pattern
Every optimistic update follows the same lifecycle:
1. User triggers action (click, form submit)
2. UI updates immediately (optimistic state)
3. Request sent to server
4. [Success] Server confirms โ update cache with real data
5. [Failure] Server rejects โ rollback to previous state + show error
React Query: List Item Toggle
The most common use case โ toggling a checkbox, completing a task, liking a post:
// components/TaskList/TaskItem.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react";
interface Task {
id: string;
title: string;
completed: boolean;
projectId: string;
}
async function toggleTaskComplete(taskId: string, completed: boolean): Promise<Task> {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error("Failed to update task");
return res.json();
}
export function TaskItem({ task }: { task: Task }) {
const queryClient = useQueryClient();
const queryKey = ["tasks", task.projectId];
const mutation = useMutation({
mutationFn: (completed: boolean) => toggleTaskComplete(task.id, completed),
// 1. BEFORE the request: optimistically update the cache
onMutate: async (newCompleted) => {
// Cancel any in-flight queries that could overwrite our optimistic update
await queryClient.cancelQueries({ queryKey });
// Snapshot the previous value for rollback
const previousTasks = queryClient.getQueryData<Task[]>(queryKey);
// Apply optimistic update to the cache
queryClient.setQueryData<Task[]>(queryKey, (old) =>
old?.map((t) => (t.id === task.id ? { ...t, completed: newCompleted } : t)) ?? []
);
// Return context for rollback
return { previousTasks };
},
// 2. On ERROR: rollback to the snapshot
onError: (_error, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(queryKey, context.previousTasks);
}
},
// 3. On SETTLE (success or error): refetch to sync with server truth
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const handleToggle = () => {
mutation.mutate(!task.completed);
};
return (
<div className="flex items-center gap-3 py-2">
<button
onClick={handleToggle}
disabled={mutation.isPending}
className={`h-5 w-5 rounded border-2 flex items-center justify-center transition-colors ${
task.completed
? "border-blue-500 bg-blue-500"
: "border-gray-300 hover:border-blue-400"
}`}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
>
{task.completed && <Check className="h-3 w-3 text-white" />}
</button>
<span className={`text-sm ${task.completed ? "line-through text-gray-400" : "text-gray-900"}`}>
{task.title}
</span>
{mutation.isError && (
<span className="text-xs text-red-500 ml-auto">
Failed โ <button onClick={handleToggle} className="underline">retry</button>
</span>
)}
</div>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
React Query: Optimistic Create (Add to List)
Adding an item to a list and showing it immediately:
// components/TaskList/AddTask.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { randomUUID } from "@/lib/utils";
interface CreateTaskInput {
title: string;
projectId: string;
}
async function createTask(input: CreateTaskInput) {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create task");
return res.json();
}
export function AddTask({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const queryKey = ["tasks", projectId];
const [title, setTitle] = useState("");
const mutation = useMutation({
mutationFn: createTask,
onMutate: async (newTask) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<Task[]>(queryKey);
// Add placeholder with temp ID (will be replaced by real ID on settle)
const optimisticTask: Task = {
id: `temp-${randomUUID()}`, // Temporary ID
title: newTask.title,
completed: false,
projectId: newTask.projectId,
};
queryClient.setQueryData<Task[]>(queryKey, (old) => [
...(old ?? []),
optimisticTask,
]);
return { previous, optimisticId: optimisticTask.id };
},
onError: (_error, _variables, context) => {
// Rollback
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSuccess: (serverTask, _variables, context) => {
// Replace optimistic item with real server data
queryClient.setQueryData<Task[]>(queryKey, (old) =>
old?.map((t) => (t.id === context?.optimisticId ? serverTask : t)) ?? []
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
mutation.mutate({ title: title.trim(), projectId });
setTitle(""); // Clear input immediately
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a task..."
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={mutation.isPending}
/>
<button
type="submit"
disabled={!title.trim() || mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm disabled:opacity-50"
>
Add
</button>
{mutation.isError && (
<p role="alert" className="text-xs text-red-500 self-center">
Failed to add task
</p>
)}
</form>
);
}
React Query: Optimistic Delete
// Optimistic delete โ remove immediately, restore on error
const deleteMutation = useMutation({
mutationFn: (taskId: string) =>
fetch(`/api/tasks/${taskId}`, { method: "DELETE" }).then((r) => {
if (!r.ok) throw new Error("Delete failed");
}),
onMutate: async (taskId) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<Task[]>(queryKey);
// Remove immediately
queryClient.setQueryData<Task[]>(queryKey, (old) =>
old?.filter((t) => t.id !== taskId) ?? []
);
return { previous };
},
onError: (_error, _taskId, context) => {
// Restore the deleted item
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
React 19: useOptimistic with Server Actions
For Next.js App Router Server Actions, React 19's useOptimistic is the native way:
// components/ReactionBar/ReactionBar.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleReaction } from "@/app/actions/reactions";
interface Reaction {
emoji: string;
count: number;
userReacted: boolean;
}
export function ReactionBar({
postId,
initialReactions,
}: {
postId: string;
initialReactions: Reaction[];
}) {
const [isPending, startTransition] = useTransition();
// useOptimistic: takes current state + update function
const [optimisticReactions, addOptimisticReaction] = useOptimistic(
initialReactions,
// Update function: (currentState, optimisticValue) => newState
(state: Reaction[], { emoji, userReacted }: { emoji: string; userReacted: boolean }) =>
state.map((r) =>
r.emoji === emoji
? {
...r,
count: userReacted ? r.count - 1 : r.count + 1,
userReacted: !userReacted,
}
: r
)
);
const handleReact = (emoji: string, currentlyReacted: boolean) => {
startTransition(async () => {
// Apply optimistic update immediately
addOptimisticReaction({ emoji, userReacted: currentlyReacted });
// Server Action โ if it fails, useOptimistic automatically rolls back
await toggleReaction(postId, emoji);
});
};
return (
<div className="flex gap-2">
{optimisticReactions.map((reaction) => (
<button
key={reaction.emoji}
onClick={() => handleReact(reaction.emoji, reaction.userReacted)}
disabled={isPending}
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-sm border transition-colors ${
reaction.userReacted
? "border-blue-400 bg-blue-50 text-blue-700"
: "border-gray-200 hover:border-gray-300 text-gray-600"
}`}
>
<span>{reaction.emoji}</span>
<span className="font-medium">{reaction.count}</span>
</button>
))}
</div>
);
}
// app/actions/reactions.ts
"use server";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";
export async function toggleReaction(postId: string, emoji: string) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const existing = await db.reaction.findUnique({
where: { postId_userId_emoji: { postId, userId: user.id, emoji } },
});
if (existing) {
await db.reaction.delete({ where: { id: existing.id } });
} else {
await db.reaction.create({ data: { postId, userId: user.id, emoji } });
}
revalidatePath(`/posts/${postId}`);
}
Handling Conflicts: Server Data Wins
After an optimistic update, the server might return different data (e.g., another user edited the same item). The onSettled โ invalidateQueries pattern handles this:
// Pattern: optimistic update + server reconciliation
onMutate: async (update) => {
await queryClient.cancelQueries({ queryKey });
const snapshot = queryClient.getQueryData(queryKey);
// Apply optimistic state immediately
queryClient.setQueryData(queryKey, applyUpdate(snapshot, update));
return { snapshot };
},
onError: (_err, _update, ctx) => {
// Rollback on error
queryClient.setQueryData(queryKey, ctx?.snapshot);
},
onSettled: () => {
// Always refetch after settle โ server data is the source of truth
// This handles: concurrent edits, server-side transformations, validation
queryClient.invalidateQueries({ queryKey });
},
The invalidateQueries in onSettled ensures the UI always converges to server truth, even when your optimistic prediction was slightly wrong.
Showing Optimistic State Visually
Indicate to users that an action is pending (avoid confusion if rollback happens):
// Subtle visual treatment for optimistic items
function OptimisticTaskItem({ task, isPending }: { task: Task; isPending: boolean }) {
return (
<div
className={`flex items-center gap-3 py-2 transition-opacity ${
isPending ? "opacity-70" : "opacity-100"
}`}
>
{/* ... */}
{isPending && (
<div className="ml-auto h-3 w-3 animate-spin rounded-full border border-gray-300 border-t-blue-500" />
)}
</div>
);
}
Common Mistakes
// โ Not cancelling in-flight queries before optimistic update
onMutate: async (update) => {
// Without cancelQueries, a background refetch could overwrite your optimistic state
const snapshot = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, applyUpdate(snapshot, update));
return { snapshot };
};
// โ
Always cancel first
onMutate: async (update) => {
await queryClient.cancelQueries({ queryKey }); // โ required
const snapshot = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, applyUpdate(snapshot, update));
return { snapshot };
};
// โ Only invalidating on success (misses error state cleanup)
onSuccess: () => queryClient.invalidateQueries({ queryKey });
// โ
Invalidate on settle (runs on both success and error)
onSettled: () => queryClient.invalidateQueries({ queryKey });
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Single optimistic toggle | 0.5 day | $300โ$500 |
| Full list CRUD (create/update/delete) | 1โ2 days | $800โ$1,600 |
| Server Action + useOptimistic | 0.5โ1 day | $400โ$800 |
| Conflict resolution + visual indicators | 0.5 day | $300โ$500 |
| Full optimistic update system | 3โ5 days | $2,500โ$4,000 |
See Also
- React Query Server State โ React Query fundamentals
- React Server Actions โ Server Actions that trigger optimistic updates
- SaaS Activity Feed โ Real-time updates alongside optimistic state
- React Testing Library Patterns โ Testing optimistic update flows
Working With Viprasol
We implement optimistic update patterns for React applications โ from simple checkbox toggles through complex multi-step form flows with rollback. Our team has shipped optimistic UIs that eliminated perceived latency for the most common user interactions.
What we deliver:
- React Query mutation setup with onMutate/onError/onSettled
- useOptimistic integration for Server Action flows
- Conflict resolution strategy (server data wins)
- Visual loading indicators for pending optimistic state
- Testing patterns for optimistic update flows
Explore our web development services or contact us to make your UI feel instant.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.