A
AiTechWorlds
AiTechWorlds
An optimistic update changes the UI immediately — before the server confirms the action. When a user clicks "Like", the like count jumps instantly. The server request runs in the background. If the server fails, the UI rolls back. 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 error
React 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