Optimistic Updates
Optimistic update patterns for instant UI feedback with server reconciliation and rollback in React
You are an expert in Optimistic Update patterns for building React applications. ## Key Points - **Immediate State Mutation**: Update the UI state as soon as the user acts, before the network round-trip completes. - **Background Server Request**: Fire the actual mutation to the server in parallel with the UI update. - **Rollback on Failure**: If the server rejects the mutation, revert the UI to the previous state and notify the user. - **Reconciliation on Success**: When the server responds, replace the optimistic data with the canonical server data. - **Idempotency**: Mutations should be idempotent so retries after transient failures do not cause duplicates. - Always snapshot the previous state before applying the optimistic update so you can roll back cleanly. - Show a subtle visual indicator (lower opacity, spinner icon) for items in an optimistic/pending state. - After server confirmation, reconcile with the actual server response rather than keeping the optimistic data — the server may normalize or transform values. - Debounce rapid mutations (e.g., toggling the same button quickly) to avoid race conditions. - Make mutations idempotent on the server so that retries are safe. - **Missing rollback**: Forgetting to restore previous state on error leaves the UI in a state that disagrees with the server. - **Race conditions**: If a user triggers multiple mutations on the same resource, responses may arrive out of order. Use sequence numbers or cancel stale requests.
skilldb get react-patterns-skills/Optimistic UpdatesFull skill: 251 linesOptimistic Updates — React Patterns
You are an expert in Optimistic Update patterns for building React applications.
Overview
Optimistic updates immediately reflect a user's action in the UI before the server confirms it. The client assumes the mutation will succeed, updates local state instantly, and then reconciles once the server responds — rolling back if the request fails. This pattern eliminates perceived latency for mutations like toggling likes, adding comments, reordering lists, and submitting forms.
Core Philosophy
Optimistic updates exist because perceived performance matters more than actual performance. A 200ms network round-trip is fast by engineering standards, but it feels sluggish to a user who just tapped a button and is waiting for visual confirmation. By updating the UI immediately and reconciling with the server afterward, you make the interface feel instant, even though the underlying operation takes the same amount of time.
This pattern requires a deliberate shift in how you think about truth. In a traditional request-response model, the server is the single source of truth and the UI waits for confirmation before changing. With optimistic updates, the client temporarily becomes the source of truth, and the server confirms or corrects after the fact. This means your code must handle three states for every mutation: the optimistic state (what the user expects to see), the confirmed state (what the server says is real), and the rollback state (what to show if the server disagrees).
The tradeoff is complexity for responsiveness. Every optimistic update must have a rollback path, a reconciliation path, and a strategy for handling concurrent mutations on the same resource. If you are not prepared to implement all three, the pattern will create more bugs than it solves. Reserve optimistic updates for mutations where the success rate is very high (above 99%), the user expectation of instant feedback is strong, and the rollback experience is not disorienting.
Core Concepts
- Immediate State Mutation: Update the UI state as soon as the user acts, before the network round-trip completes.
- Background Server Request: Fire the actual mutation to the server in parallel with the UI update.
- Rollback on Failure: If the server rejects the mutation, revert the UI to the previous state and notify the user.
- Reconciliation on Success: When the server responds, replace the optimistic data with the canonical server data.
- Idempotency: Mutations should be idempotent so retries after transient failures do not cause duplicates.
Implementation Patterns
Basic Optimistic Toggle with useState
import { useState } from "react";
function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
const [liked, setLiked] = useState(initialLiked);
const [pending, setPending] = useState(false);
async function handleToggle() {
const previousLiked = liked;
setLiked(!liked); // Optimistic update
setPending(true);
try {
const res = await fetch(`/api/posts/${postId}/like`, {
method: liked ? "DELETE" : "POST",
});
if (!res.ok) throw new Error("Failed");
} catch {
setLiked(previousLiked); // Rollback
} finally {
setPending(false);
}
}
return (
<button onClick={handleToggle} disabled={pending} aria-pressed={liked}>
{liked ? "Unlike" : "Like"}
</button>
);
}
React Query Optimistic Mutation
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface Todo {
id: string;
title: string;
completed: boolean;
}
function useTodoToggle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !todo.completed }),
}).then((r) => {
if (!r.ok) throw new Error("Failed to update");
return r.json() as Promise<Todo>;
}),
onMutate: async (todo) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ["todos"] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
// Optimistically update the cache
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old?.map((t) => (t.id === todo.id ? { ...t, completed: !t.completed } : t))
);
return { previousTodos };
},
onError: (_err, _todo, context) => {
// Rollback to snapshot
if (context?.previousTodos) {
queryClient.setQueryData(["todos"], context.previousTodos);
}
},
onSettled: () => {
// Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
React 19 useOptimistic Hook
import { useOptimistic, useTransition } from "react";
interface Message {
id: string;
text: string;
sending?: boolean;
}
function Chat({ messages: serverMessages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[], string>(
serverMessages,
(state, newText) => [
...state,
{ id: `temp-${Date.now()}`, text: newText, sending: true },
]
);
const [isPending, startTransition] = useTransition();
async function sendMessage(formData: FormData) {
const text = formData.get("message") as string;
startTransition(async () => {
addOptimisticMessage(text);
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
});
}
return (
<div>
<ul>
{optimisticMessages.map((msg) => (
<li key={msg.id} style={{ opacity: msg.sending ? 0.6 : 1 }}>
{msg.text}
</li>
))}
</ul>
<form action={sendMessage}>
<input name="message" required />
<button type="submit">Send</button>
</form>
</div>
);
}
Optimistic List Reordering
import { useState } from "react";
interface Item {
id: string;
position: number;
title: string;
}
function SortableList({ initialItems }: { initialItems: Item[] }) {
const [items, setItems] = useState(initialItems);
async function handleReorder(dragId: string, targetIndex: number) {
const previousItems = [...items];
// Optimistically reorder
const dragIndex = items.findIndex((i) => i.id === dragId);
const reordered = [...items];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(targetIndex, 0, moved);
const withPositions = reordered.map((item, i) => ({ ...item, position: i }));
setItems(withPositions);
try {
const res = await fetch("/api/items/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
itemId: dragId,
newPosition: targetIndex,
}),
});
if (!res.ok) throw new Error("Reorder failed");
} catch {
setItems(previousItems); // Rollback
}
}
return (
<ul>
{items.map((item, index) => (
<li key={item.id} draggable onDrop={() => handleReorder(item.id, index)}>
{item.title}
</li>
))}
</ul>
);
}
Best Practices
- Always snapshot the previous state before applying the optimistic update so you can roll back cleanly.
- Show a subtle visual indicator (lower opacity, spinner icon) for items in an optimistic/pending state.
- After server confirmation, reconcile with the actual server response rather than keeping the optimistic data — the server may normalize or transform values.
- Debounce rapid mutations (e.g., toggling the same button quickly) to avoid race conditions.
- Make mutations idempotent on the server so that retries are safe.
Common Pitfalls
- Missing rollback: Forgetting to restore previous state on error leaves the UI in a state that disagrees with the server.
- Race conditions: If a user triggers multiple mutations on the same resource, responses may arrive out of order. Use sequence numbers or cancel stale requests.
- Stale closures: The rollback function must capture the state as it was before the optimistic update, not the current state at the time of rollback.
- No error feedback: Silently rolling back without telling the user is confusing. Show a toast or inline error message.
- Optimistic deletes without undo: Optimistically removing an item with no undo mechanism frustrates users when the deletion was accidental. Provide a brief undo window or confirmation.
Anti-Patterns
-
Optimistic updates for unreliable operations: Applying optimistic UI to mutations that frequently fail (payment processing, third-party API calls, complex validations). When rollbacks happen often, users lose trust in the interface because it repeatedly shows success before reverting.
-
Snapshot-less rollback: Attempting to roll back by reversing the mutation (e.g., decrementing a counter after an increment fails) instead of restoring a snapshot of the previous state. Reverse operations are error-prone when concurrent mutations overlap.
-
Ignoring concurrent mutations: Allowing multiple optimistic updates on the same resource without serialization or conflict detection. Two rapid toggles on the same item can produce a state where the UI shows the opposite of what the server accepted.
-
Keeping optimistic data after server confirmation: Trusting the optimistic value permanently instead of replacing it with the server's canonical response. The server may normalize, transform, or enrich the data (e.g., adding timestamps, computed fields), and the UI should reflect the real result.
-
No user feedback on rollback: Silently reverting the UI to its previous state after a server failure. The user performed an action, saw it succeed, and then it vanished. Without a toast, inline error, or other notification, this feels like a bug rather than a recoverable failure.
Install this skill directly: skilldb add react-patterns-skills
Related Skills
Compound Components
Compound component pattern for building flexible, implicitly-shared React component APIs
Context Patterns
React Context patterns for efficient state sharing, provider composition, and avoiding unnecessary re-renders
Custom Hooks
Custom hooks pattern for extracting and reusing stateful logic across React components
Error Boundaries
Error boundary pattern for gracefully catching and recovering from runtime errors in React component trees
Render Props
Render props pattern for sharing cross-cutting logic through function-as-children and render callbacks
Server Components
React Server Components pattern for zero-bundle server-rendered components with direct backend access