The Problem: Five Clicks, One Race Condition

You wire up useOptimistic on a task list. The checkbox flips instantly, no spinner, no lag. It feels perfect. Then you do what any real user would: click five times fast. The UI stays visually fine, but the database gets five overlapping requests, each flipping the same boolean, arriving in arbitrary order. The UI silently diverges from the server state. No error, no crash, just a quiet mismatch.

This is the specific danger of optimistic UI: it looks right immediately, but looking right and being right are not the same thing.

The Fix: Per-Row Pending State

The solution isn't specific to useOptimistic. It's the same pattern as disabling a submit button while a form is in flight, applied per row. Track a pendingId and disable the checkbox until the request settles.

const [pendingId, setPendingId] = useState(null);

function handleToggle(id: string) {
  setPendingId(id);
  startTransition(async () => {
    setOptimisticTask(id);
    try {
      await toggleTask(id);
    } finally {
      setPendingId(null);
    }
  });
}
 handleToggle(task.id)}
/>

One item at a time, scoped per row. The second click does nothing instead of firing a redundant request. Five rapid clicks = one request.

Automatic Rollback: When It Works and When It Doesn't

Many examples show this pattern for rollback on failure:

try {
  setOptimisticTask(id);
  await toggleTask(id);
} catch {
  setOptimisticTask(id); // flip it back
}

For a simple toggle, this is unnecessary. useOptimistic gives you a temporary value layered on top of the real state. Once the transition settles (success or failure), React drops the temporary layer and renders from the real state again. If the request failed and nothing re-fetched, the checkbox reverts automatically. No second dispatch required.

The exception: when the optimistic value doesn't exist in the base state yet — like a newly added comment with a placeholder ID. A failed insert means removing something that only existed on the client. For that case, you need to manually clear your own local record in the catch block. A toggle doesn't have that problem; the value already exists on the server.

Cache Invalidation: Choose Wisely

Next.js 16 offers three cache invalidation functions. Picking the wrong one shows stale data or blocks every page on a single write.

For a checkbox the user is staring at, updateTag is the right choice. If a sidebar stat elsewhere uses the same tag but nobody's watching it, revalidateTag lets it catch up later without blocking the user's page.

Important: In recent Next.js 16 versions, revalidateTag requires a second argument:

revalidateTag("tasks", "max"); // recommended in Next.js 16+

revalidateTag("tasks") alone is deprecated and will throw a TypeScript error.

Error Boundaries: unstable_retry vs reset

When a request fails outright (dropped connection), Next.js docs say an error thrown inside startTransition bubbles to the nearest error.tsx. But as of Next.js 16.2, error.tsx ships a new prop: unstable_retry, alongside the older reset.

They are not interchangeable:

export default function TaskListError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    
      <p>Something went wrong loading your tasks.</p>
       unstable_retry()}&gt;Try again
    
  );
}

reset still has its place: when you want to clear an error and re-render without hitting the network. For data-fetching segments, that's rarely the case.

What You Should Do Now

  1. Add per-row pending state to every optimistic toggle. Disable the input while the request is in flight.
  2. For cache invalidation, use updateTag for the current user's immediate write, and revalidateTag with the second argument for background updates.
  3. In error boundaries, use unstable_retry instead of reset when the error came from a data fetch.
  4. Test your UI by clicking rapidly — simulate impatient users before shipping.