AiTechWorlds
AiTechWorlds
An optimistic update changes the UI immediately, before the server confirms the action, then rolls back only if the server rejects it.
It works like a cashier handing you your receipt the instant you tap your card, trusting the bank to approve the charge a moment later, rather than making you stand and wait for confirmation before you can leave. When a user clicks "Like," the like count jumps instantly; the server request runs in the background, and the UI rolls back only if that request fails. The result feels instant even on slow connections.
User clicks "Like"
→ Immediately update local state (optimistic)
→ Send request to server in background
→ If server succeeds: done (keep the optimistic state)
→ If server fails: roll back to previous state + show errorReact 19 ships useOptimistic — the cleanest way to implement this pattern:
"use client";
import { useOptimistic, useTransition } from "react";
interface Post {
id: string;
title: string;
likes: number;
liked: boolean;
}
function PostCard({ post }: { post: Post }) {
const [isPending, startTransition] = useTransition();
const [optimisticPost, addOptimisticUpdate] = useOptimistic(
post,
(currentPost, liked: boolean) => ({
...currentPost,
liked,
likes: liked ? currentPost.likes + 1 : currentPost.likes - 1,
})
);
function handleLike() {
const newLiked = !optimisticPost.liked;
startTransition(async () => {
addOptimisticUpdate(newLiked); // Update UI immediately
await fetch(`/api/posts/${post.id}/like`, {
method: newLiked ? "POST" : "DELETE",
});
// Server action completes — React merges real state
});
}
return (
<div>
<h3>{post.title}</h3>
<button
onClick={handleLike}
className={optimisticPost.liked ? "text-red-500" : "text-gray-400"}
disabled={isPending}
>
♥ {optimisticPost.likes}
</button>
</div>
);
}"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface Todo {
id: string;
text: string;
completed: boolean;
}
function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (completed: boolean) =>
fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
}).then(r => r.json()),
// Before the mutation fires: optimistically update the cache
onMutate: async (completed) => {
// Cancel any outgoing refetches (prevent overwrites)
await queryClient.cancelQueries({ queryKey: ["todos"] });
// Snapshot the previous value (for rollback)
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
// Optimistically update the cache
queryClient.setQueryData<Todo[]>(["todos"], (old = []) =>
old.map(t => t.id === todo.id ? { ...t, completed } : t)
);
return { previous }; // Return context for rollback
},
// If the mutation fails: roll back
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(["todos"], context.previous);
}
},
// Always: refetch to sync with server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<div className={`flex items-center gap-3 ${mutation.isPending ? "opacity-70" : ""}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={e => mutation.mutate(e.target.checked)}
/>
<span className={todo.completed ? "line-through text-gray-400" : ""}>
{todo.text}
</span>
</div>
);
}Removing an item from a list is one of the most common optimistic updates:
function CommentList({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const { data: comments } = useQuery({
queryKey: ["comments", postId],
queryFn: () => fetchComments(postId),
});
const deleteMutation = useMutation({
mutationFn: (commentId: string) =>
fetch(`/api/comments/${commentId}`, { method: "DELETE" }),
onMutate: async (commentId) => {
await queryClient.cancelQueries({ queryKey: ["comments", postId] });
const previous = queryClient.getQueryData(["comments", postId]);
// Remove from cache immediately
queryClient.setQueryData<Comment[]>(["comments", postId], (old = []) =>
old.filter(c => c.id !== commentId)
);
return { previous };
},
onError: (err, commentId, context) => {
queryClient.setQueryData(["comments", postId], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["comments", postId] });
},
});
return (
<ul>
{comments?.map(comment => (
<li key={comment.id} className="flex items-start justify-between gap-4 py-3 border-b">
<p>{comment.text}</p>
<button
onClick={() => deleteMutation.mutate(comment.id)}
disabled={deleteMutation.isPending && deleteMutation.variables === comment.id}
className="text-red-500 hover:text-red-600 text-sm flex-shrink-0"
>
Delete
</button>
</li>
))}
</ul>
);
}import useSWR, { useSWRConfig } from "swr";
function LikeButton({ postId }: { postId: string }) {
const { mutate } = useSWRConfig();
async function handleLike() {
// Optimistic update — don't wait for revalidation
mutate(
`/api/posts/${postId}`,
async (current: Post) => {
// Optimistic state while request is in flight
const optimistic = {
...current,
liked: !current.liked,
likes: current.liked ? current.likes - 1 : current.likes + 1,
};
// Run the actual request
await fetch(`/api/posts/${postId}/like`, {
method: current.liked ? "DELETE" : "POST",
});
return optimistic;
},
{ optimisticData: (current: Post) => ({
...current,
liked: !current.liked,
likes: current.liked ? current.likes - 1 : current.likes + 1,
}), rollbackOnError: true }
);
}
const { data: post } = useSWR(`/api/posts/${postId}`, fetcher);
return (
<button onClick={handleLike} className={post?.liked ? "text-red-500" : "text-gray-400"}>
♥ {post?.likes ?? 0}
</button>
);
}Use optimistic updates when:
Don't use optimistic updates when:
Next lesson: Authentication — sessions, JWT, and OAuth explained.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises