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.
Before reaching for a library like React Query or Zustand, it is worth deeply understanding the simplest possible optimistic state implementation: a plain useState variable that holds the displayed value, a captured previous variable for rollback, and a try/catch around the async mutation. This pattern is sufficient for the majority of single-field optimistic updates in a real product — the toggle of a task's completion status, the like state on a post, the muted/unmuted state of a notification. Understanding its limits (it does not handle rapid concurrent mutations on the same item) is exactly what motivates the more complex patterns in later modules.
A task completion toggle that updates instantly, fires a PATCH request to a fake API, and reverts to the pre-click state if the server returns an error.
disabled state that prevents double-clicks during the 500ms window — verify the checkbox cannot be toggled again until the first mutation settles.const previous = completed capture and attempt the rollback with setCompleted(completed) — observe in the console that completed has already changed inside the catch block and the rollback is now incorrect.<TaskRow> components in a list and toggle each one rapidly — verify each row maintains its own independent optimistic and rollback state.Math.random() < 0.15 with a counter so every 5th toggle fails deterministically — use this to verify the rollback fires exactly once per failure.Use these three in order. Each builds on the one before.
In one paragraph, explain why storing a `previous` value before calling `setState` is the critical step that makes useState-based optimistic updates safe to roll back.
Walk me through what React does internally when `setState` is called twice in rapid succession inside the same async function — does it batch the calls or flush immediately?
Describe a scenario where using a plain `useState` optimistic pattern breaks down and the user sees an incorrect final state — and explain which pattern from Module 2 fixes it.
import { useState } from "react";
interface Task {
id: string;
title: string;
completed: boolean;
}
async function patchTask(id: string, completed: boolean): Promise<void> {
await new Promise((r) => setTimeout(r, 500));
if (Math.random() < 0.15) throw new Error("PATCH failed");
}
export function TaskRow({ task }: { task: Task }) {
const [completed, setCompleted] = useState(task.completed);
const [error, setError] = useState<string | null>(null);
async function handleToggle() {
const previous = completed;
setCompleted(!previous); // optimistic
setError(null);
try {
await patchTask(task.id, !previous);
} catch {
setCompleted(previous); // rollback
setError("Couldn't update task.");
}
}
return (
<li>
<input type="checkbox" checked={completed} onChange={handleToggle} />
<span style={{ textDecoration: completed ? "line-through" : "none" }}>
{task.title}
</span>
{error && <span style={{ color: "red", marginLeft: 8 }}>{error}</span>}
</li>
);
}