Implementing Optimistic Updates in React: Mutation Strategies, Rollback Patterns, and Server Reconciliation
How to build React UIs that update immediately on user action and safely reconcile with server state, covering useOptimistic, TanStack Query, error rollback, and conflict resolution.
Every form submit that spins for 400ms is a small tax on the people using your application. Multiply that across every toggle, every list reorder, every like button across thousands of daily interactions, and the cost is real. Optimistic updates fix this by rendering the expected outcome immediately, then reconciling with whatever the server actually returns.
The concept is simple. The implementation details are where teams hit trouble: partial failures that leave stale state, retries that double-apply mutations, conflict windows where the server returns something different from what you assumed, and lists that flicker when IDs shift from temp to real. This article works through those problems concretely.
The Core Contract
An optimistic update makes a local state change before the server confirms it. It then awaits the server response and either keeps the optimistic state (on success) or reverts to the previous state (on failure). The mental model is:
- Capture current state as a rollback snapshot
- Apply the expected mutation to local state immediately
- Fire the network request
- On success: replace optimistic state with server-confirmed state
- On failure: restore snapshot, surface the error, optionally retry
The hard part is step 4. “Replace with server-confirmed state” sounds trivial until the server returns a different shape than you assumed, or multiple mutations are in-flight simultaneously, or the user navigated away.
useOptimistic: React’s Built-In Primitive
React 19 shipped useOptimistic for this exact pattern. It works alongside Server Actions but is not exclusive to them.
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTodoComplete } from "./actions";
type Todo = {
id: string;
text: string;
completed: boolean;
};
type Props = {
todos: Todo[];
};
export function TodoList({ todos }: Props) {
const [optimisticTodos, applyOptimisticUpdate] = useOptimistic(
todos,
(current: Todo[], { id, completed }: { id: string; completed: boolean }) =>
current.map((t) => (t.id === id ? { ...t, completed } : t))
);
const [isPending, startTransition] = useTransition();
async function handleToggle(todo: Todo) {
startTransition(async () => {
applyOptimisticUpdate({ id: todo.id, completed: !todo.completed });
await toggleTodoComplete(todo.id, !todo.completed);
});
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: isPending ? 0.7 : 1 }}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
/>
{todo.text}
</li>
))}
</ul>
);
}
A few things worth noting here. useOptimistic takes the canonical state (todos from the server) and a reducer function. The reducer receives the current optimistic state and an action you define. When the transition completes, React automatically reverts to the canonical state if the action throws, or keeps it if the action succeeds and triggers a re-render with fresh server data.
The revert is implicit. If toggleTodoComplete throws, React rolls back to the todos prop. This is the key difference from managing optimistic state manually with useState: you do not write rollback logic yourself.
Limitations of useOptimistic
The implicit rollback is convenient, but it also means you lose control over the exact rollback moment. If you need to show a specific error message alongside the reverted state, or if you want to retry before reverting, you need a more explicit approach. useOptimistic also does not handle concurrent mutations on the same item cleanly. If the user toggles the same item twice before the first request resolves, the second applyOptimisticUpdate merges with the first, but the rollback target is always the original todos prop. This can produce unexpected intermediate states.
TanStack Query: Explicit Mutation Pipeline
TanStack Query exposes optimistic mutations through onMutate, onError, and onSettled lifecycle hooks. This gives you full control over the mutation pipeline.
import {
useMutation,
useQueryClient,
useQuery,
} from "@tanstack/react-query";
type Comment = {
id: string;
postId: string;
body: string;
authorId: string;
createdAt: string;
};
type AddCommentInput = {
postId: string;
body: string;
authorId: string;
};
async function addComment(input: AddCommentInput): Promise<Comment> {
const res = await fetch(`/api/posts/${input.postId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to add comment");
return res.json();
}
function useAddComment(postId: string) {
const queryClient = useQueryClient();
const queryKey = ["comments", postId];
return useMutation({
mutationFn: addComment,
onMutate: async (input) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey });
// Snapshot the previous value for rollback
const previousComments = queryClient.getQueryData<Comment[]>(queryKey);
// Build a temporary optimistic comment
const optimisticComment: Comment = {
id: `temp-${Date.now()}`,
postId: input.postId,
body: input.body,
authorId: input.authorId,
createdAt: new Date().toISOString(),
};
// Optimistically update the cache
queryClient.setQueryData<Comment[]>(queryKey, (old = []) => [
...old,
optimisticComment,
]);
// Return context for rollback
return { previousComments, optimisticId: optimisticComment.id };
},
onError: (_err, _input, context) => {
if (context?.previousComments !== undefined) {
queryClient.setQueryData(queryKey, context.previousComments);
}
},
onSettled: () => {
// Always refetch after error or success to sync with server truth
queryClient.invalidateQueries({ queryKey });
},
});
}
The pattern: onMutate runs synchronously before the network request. It cancels in-flight refetches (critical), snapshots the current cache state, applies the optimistic update, and returns a context object. onError uses that context to restore the snapshot. onSettled invalidates the query regardless of outcome so the cache eventually converges on server truth.
The cancelQueries call is not optional. Without it, a background refetch completing between onMutate and the mutation response will overwrite your optimistic state with the old server state, producing a visible flicker.
Replacing the Temp ID After Server Confirmation
A common pain point with list mutations is the temp-to-real ID transition. When the server returns the real comment, onSettled invalidates and refetches, replacing the temp item. But during the window between optimistic insert and server refetch, any code that references the temp ID (a delete button, a reply form) will fail when the real data arrives.
One mitigation: map temp IDs to real IDs in a ref, and use that map in any subsequent mutation.
const tempToRealId = useRef<Map<string, string>>(new Map());
// In onSuccess:
onSuccess: (data, _variables, context) => {
if (context?.optimisticId) {
tempToRealId.current.set(context.optimisticId, data.id);
}
},
For most CRUD lists, the refetch-on-settle approach is simpler: accept a brief re-render and let server truth win. Only reach for manual ID mapping if your UI has downstream interactions that depend on the ID before the refetch completes.
Error Rollback and Retry
Rollback is straightforward. Retry is trickier because a naive retry can double-apply side effects.
function useAddCommentWithRetry(postId: string) {
const queryClient = useQueryClient();
const queryKey = ["comments", postId];
return useMutation({
mutationFn: addComment,
retry: (failureCount, error) => {
// Only retry network errors, not 4xx responses
if (error instanceof Response && error.status < 500) return false;
return failureCount < 2;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 8000),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey });
const previousComments = queryClient.getQueryData<Comment[]>(queryKey);
queryClient.setQueryData<Comment[]>(queryKey, (old = []) => [
...old,
{
id: `temp-${Date.now()}`,
postId: input.postId,
body: input.body,
authorId: input.authorId,
createdAt: new Date().toISOString(),
},
]);
return { previousComments };
},
onError: (_err, _input, context) => {
if (context?.previousComments !== undefined) {
queryClient.setQueryData(queryKey, context.previousComments);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}
TanStack Query’s retry applies to mutationFn only. onMutate runs once. So retries do not re-apply the optimistic update, but they also do not undo the first rollback. If onError fires on attempt 1, the optimistic item is gone. On attempt 2 (the retry), the item is not re-added optimistically. Users see the item disappear and reappear after the successful retry, which is jarring.
The fix: track retry state in the mutation context and only roll back on final failure.
onError: (_err, _input, context) => {
// Do not roll back if the mutation will retry
// TanStack Query sets failureCount on the mutation object,
// but we cannot access it in onError directly.
// Instead, only roll back in onSettled when status === "error"
},
onSettled: (_data, error, _variables, context) => {
if (error && context?.previousComments !== undefined) {
queryClient.setQueryData(queryKey, context.previousComments);
}
queryClient.invalidateQueries({ queryKey });
},
Moving rollback to onSettled means it only fires after all retries are exhausted, which preserves the optimistic state through transient failures.
Conflict Resolution When Server State Diverges
Sometimes the server returns state that diverges from what you assumed. A toggle that you set to true comes back false because another client flipped it in the same second. A balance field you decremented comes back with a different value because a concurrent transaction posted.
For toggles, the refetch-on-settle strategy handles this automatically: after the server confirms the mutation, you invalidate and refetch the real state. The optimistic value was a best guess for perceived responsiveness, not a commitment.
For forms with multiple fields, the divergence window is longer. A user editing a profile form and submitting triggers an optimistic update across 5 fields. The server might reject 2 of them due to validation rules it couldn’t surface in the client. A naive rollback reverts all 5 fields, including the 3 that were fine.
A better approach: merge the server response onto the optimistic state rather than choosing one or the other wholesale.
onSuccess: (serverData, _variables, context) => {
// Merge server truth with current cache state.
// Server fields win over optimistic fields.
queryClient.setQueryData(queryKey, (current: UserProfile | undefined) => {
if (!current) return serverData;
return { ...current, ...serverData };
});
},
This works when the server returns the full updated resource. If your API returns only the changed fields (a PATCH response), apply the server delta over current cache state rather than over the optimistic state directly.
Patterns for Specific UI Shapes
Toggle Buttons
Toggles are the simplest optimistic case. Flip the boolean locally, fire the request, revert on failure.
function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
const [liked, setLiked] = useState(initialLiked);
const [pending, setPending] = useState(false);
async function handleClick() {
if (pending) return; // Prevent double-fire
const next = !liked;
setLiked(next);
setPending(true);
try {
await fetch(`/api/posts/${postId}/like`, {
method: next ? "POST" : "DELETE",
});
} catch {
setLiked(!next); // Roll back
} finally {
setPending(false);
}
}
return (
<button onClick={handleClick} aria-pressed={liked} disabled={pending}>
{liked ? "Unlike" : "Like"}
</button>
);
}
The if (pending) return guard prevents the user from toggling faster than the server responds and producing an inconsistent state. For toggles you care about strongly (billing toggles, permission toggles), add a debounce or disable the button during the request rather than relying on the guard.
Forms
Forms have more surface area. The safest optimistic strategy for a form is to only update derived/display state, not the form’s own input state.
function ProfileForm({ profile }: { profile: UserProfile }) {
const { mutate, status } = useUpdateProfile();
const [optimisticName, setOptimisticName] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const name = data.get("name") as string;
setOptimisticName(name); // Update display immediately
mutate(
{ name },
{
onError: () => setOptimisticName(null), // Revert display
onSuccess: () => setOptimisticName(null), // Let server data take over
}
);
}
return (
<form onSubmit={handleSubmit}>
<p>Current name: {optimisticName ?? profile.name}</p>
<input name="name" defaultValue={profile.name} />
<button type="submit" disabled={status === "pending"}>
Save
</button>
</form>
);
}
Do not set value (controlled) on the input to the optimistic value. If you do, a rollback will reset what the user typed, which is worse than showing the old server value in a separate display area.
Ordered Lists
Reorder mutations (drag and drop, up/down buttons) require applying the new order optimistically while the server persists it.
onMutate: async ({ listId, itemId, newIndex }) => {
await queryClient.cancelQueries({ queryKey: ["list", listId] });
const previous = queryClient.getQueryData<ListItem[]>(["list", listId]);
queryClient.setQueryData<ListItem[]>(["list", listId], (items = []) => {
const result = [...items];
const fromIndex = result.findIndex((i) => i.id === itemId);
if (fromIndex === -1) return items;
const [moved] = result.splice(fromIndex, 1);
result.splice(newIndex, 0, moved);
return result;
});
return { previous };
},
The risk: if the server rejects the reorder (a concurrent edit changed positions), the rollback puts the user back to a list order that may also be stale. After a failed reorder, always refetch to show the actual current order rather than the pre-drag snapshot.
Tradeoffs
| Approach | Rollback Control | Concurrent Mutations | Setup Cost | Best For |
|---|---|---|---|---|
useOptimistic (React 19) | Automatic/implicit | Limited (last write wins) | Low | Server Actions, simple toggles |
TanStack Query onMutate | Explicit, full control | Manual coordination needed | Medium | REST/GraphQL, complex lists |
Manual useState | Explicit | Full control | High | One-off components, no library |
SWR optimisticData | Semi-automatic | Limited | Low | Simple cases with SWR |
Production Considerations
Concurrent mutations on the same resource. Two mutations in-flight simultaneously can produce rollback collisions. If mutation A snapshots state S0 and mutation B snapshots state S1 (after A’s optimistic update), B’s rollback target includes A’s optimistic change. If A then fails, both roll back independently, and the final state may not match any real server state. For critical mutations, serialize them: queue the second mutation until the first settles.
Network condition awareness. On slow connections, the window between optimistic update and server confirmation is long. Consider showing a subtle pending indicator (reduced opacity, a spinner badge) so users know the mutation is in-flight. Do not block the UI, but do signal uncertainty.
Mutation deduplication. If the user can trigger the same mutation faster than the server responds (rapid-fire like button), each click creates a new in-flight request. The last response to arrive wins, which may not be the last click. Debounce or use a queue where only the latest intent matters.
Idempotency keys. For mutations that must not double-apply (payment submissions, order creates), generate an idempotency key client-side and include it in the request. If a retry fires after a silent server success, the server returns the original result rather than creating a duplicate.
const idempotencyKey = useRef<string>(crypto.randomUUID());
// Reset after confirmed success
onSuccess: () => {
idempotencyKey.current = crypto.randomUUID();
},
mutationFn: (input) =>
fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey.current,
},
body: JSON.stringify(input),
}),
Testing optimistic paths. Unit tests for optimistic updates need to simulate delayed server responses. Wrap your test server mock in a controlled promise and verify the intermediate state before resolving.
it("shows optimistic state before server confirms", async () => {
let resolveToggle!: () => void;
server.use(
http.post("/api/todos/:id/toggle", () =>
new Promise<Response>((resolve) => {
resolveToggle = () => resolve(new Response(null, { status: 200 }));
})
)
);
render(<TodoList todos={[{ id: "1", text: "Buy milk", completed: false }]} />);
await userEvent.click(screen.getByRole("checkbox"));
// Optimistic state: checkbox should be checked before server responds
expect(screen.getByRole("checkbox")).toBeChecked();
// Resolve and verify no flicker
resolveToggle();
await screen.findByRole("checkbox", { checked: true });
});
Closing
Optimistic updates are a responsiveness technique, not a consistency one. They are always a best guess at what the server will confirm. The implementation work is not in the happy path: it is in deciding what to do when the guess is wrong, when two guesses conflict, and when the server comes back with something you did not expect. Get the rollback right, serialize mutations when order matters, and always converge on server truth through invalidation or a merge. The UI can lie briefly to feel fast; it should never lie permanently.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.