Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
Shipping an optimistic update without a rollback is not a faster version of the pessimistic pattern — it is a correctness bug. When the server rejects a mutation, the UI now shows a state that does not exist on the server. The user has no way to know what actually happened: did the action succeed? Is the UI lying? Over time, these silent failures erode trust. The rollback requirement is not optional; it is the contract that makes optimism safe. Implementing it correctly means capturing the pre-mutation state before calling setState, not after, and restoring it unconditionally in the catch block, regardless of how many times the user has clicked in the interim.
A like button with a 20% random failure rate. The button reverts its visual state and shows an error toast when the server rejects the mutation, making the rollback behavior explicit and observable.
1.0 (always fail) and click the button ten times — confirm the count returns to exactly 42 after each click and never drifts.const prevLiked = liked to after setLiked(!liked) and observe that the rollback now stores the wrong value — verify by checking what the button reverts to after a failure.console.log('rolling back from', !prevLiked, 'to', prevLiked) in the catch block and verify the logged values match the visible button state after each failed click.handleLike() again — wire it using the current liked state so retrying re-enters the full optimistic flow.Use these three in order. Each builds on the one before.
In one paragraph, explain why capturing state before a mutation (not after) is essential for a correct rollback implementation.
Walk me through what happens at the React reconciliation level when a setState rollback fires inside a catch block — does React batch it with any pending state updates?
If a user performs three rapid optimistic updates on the same item (like, unlike, like) and the second one fails, describe the exact rollback behavior your implementation should have and whether a simple single-variable capture handles it correctly.
import { useState } from "react";
async function likePost(id: string, liked: boolean): Promise<void> {
await new Promise((r) => setTimeout(r, 500));
if (Math.random() < 0.2) throw new Error("Rate limited");
}
export function LikeWithRollback({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(42);
const [toast, setToast] = useState<string | null>(null);
async function handleLike() {
const prevLiked = liked;
const prevCount = count;
// capture before mutating
setLiked(!prevLiked);
setCount((c) => (prevLiked ? c - 1 : c + 1));
setToast(null);
try {
await likePost(postId, !prevLiked);
} catch {
// restore exactly what we captured
setLiked(prevLiked);
setCount(prevCount);
setToast("Couldn't save your like. Try again.");
}
}
return (
<div>
<button onClick={handleLike}>
{liked ? "❤️" : "🤍"} {count}
</button>
{toast && (
<div role="alert" style={{ background: "#fee", padding: 8, marginTop: 8 }}>
{toast}
</div>
)}
</div>
);
}