The Problem
I ran into this on a client dashboard built with Next.js 16 and React 19.2. The page has a list of tasks. Each task has a "Mark complete" button wired to a server action. The button used useOptimistic to flip the task to "Completed" instantly while the server action processed in the background, then a revalidation was supposed to confirm the new state from the database.
The optimistic flip worked. The server action ran. The database updated. But the optimistic state never reset. The UI kept showing the optimistic value forever, even when the underlying server data changed, and even when the server action returned an error. Refreshing the page resolved the state, but no client-side navigation cleared it.
The bug wasn't even visible most of the time. The optimistic UI looked correct because the optimistic guess matched reality. The problem only showed up when an action failed: the task would stay marked "Completed" even though the server rejected the update.
Why It Happens
useOptimistic provides a transient overlay that lives only inside the transition that triggered it. The hook resets when the surrounding useTransition (or the implicit transition from a form action) finishes, but "finishes" here means React commits new props for the component that owns the optimistic state.
In React 19.2, two things make this trickier than in 19.0:
First, the implicit transition from <form action={...}> finishes as soon as the server action's promise resolves, which is before the revalidation round-trip lands new props on the client. For one render the UI flashes back to the pre-optimistic value, then the new props arrive and it jumps to the new value. Most teams "fix" the flash by holding the optimistic state with extra useState, which causes the stuck-state bug.
Second, useOptimistic requires the same component instance to receive new props for the reset to fire. If your task list is rendered by a server component that re-renders on revalidation, the list's client component may remount instead of receiving new props. The optimistic state then lives on a destroyed component, and the visible state is whatever rendered before the remount.
The React useOptimistic docs cover the happy path but do not describe the remount case.
The Fix
Three changes: keep the optimistic state on a stable component, use startTransition explicitly so you control when it ends, and pair it with revalidatePath server-side.
Step 1: Move useOptimistic to a stable client component above the list. If your individual task items are client components, the wrapper should be one level up so it survives revalidations of the children:
'use client';
import { useOptimistic, useTransition } from 'react';
import { completeTask } from './actions';
type Task = { id: string; title: string; done: boolean };
export function TaskList({ tasks }: { tasks: Task[] }) {
const [optimisticTasks, addOptimistic] = useOptimistic(
tasks,
(current, completedId: string) =>
current.map(t => t.id === completedId ? { ...t, done: true } : t)
);
const [isPending, startTransition] = useTransition();
return (
<ul>
{optimisticTasks.map(task => (
<li key={task.id}>
{task.title} {task.done && '✓'}
{!task.done && (
<form action={() => {
startTransition(async () => {
addOptimistic(task.id);
await completeTask(task.id);
});
}}>
<button disabled={isPending} type="submit">Complete</button>
</form>
)}
</li>
))}
</ul>
);
}
The tasks prop comes from the server component above. When the server revalidates and passes a new tasks array, this client component receives new props, React reconciles, and useOptimistic resets to the new base.
Step 2: Wrap the optimistic update and the action call in startTransition. The implicit transition from <form action={...}> closes as soon as the action's promise resolves, before revalidation arrives. Using an explicit startTransition ties both calls to the same transition that React keeps open until the new props land. That removes the back-to-base flash and the remount race.
Step 3: Revalidate the right path server-side. The action needs to call revalidatePath so the parent server component re-renders and passes fresh tasks down:
// app/dashboard/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function completeTask(id: string) {
await db.task.update({ where: { id }, data: { done: true } });
revalidatePath('/dashboard');
}
If the action throws (network error, validation failure), the optimistic state resolves to the previous tasks value because React never receives a new base, and the UI rolls back. That is the correct behaviour.
Step 4: Handle the error path with returned state. If you need to show a toast on failure, return a status from the action and merge it into the optimistic reducer rather than throwing:
type Action =
| { kind: 'complete'; id: string }
| { kind: 'rollback'; id: string };
const [optimistic, dispatch] = useOptimistic<Task[], Action>(
tasks,
(current, action) => {
if (action.kind === 'rollback') return current;
return current.map(t =>
t.id === action.id ? { ...t, done: true } : t
);
}
);
Then in the form handler dispatch { kind: 'rollback', id } if the server returns { ok: false }. The optimistic row drops on the next render and you can fire a toast without waiting for a full revalidation.
Step 5: Verify with the React DevTools profiler. Record an interaction, expand the TaskList component, and watch the useOptimistic hook value. It should briefly show the optimistic state during the transition and snap to the confirmed value once revalidatePath returns. If it stays on the optimistic value past the transition, you are still hitting one of the two causes above.
The Lesson
useOptimistic resets when the component owning the state receives new props. If your list remounts on revalidation, the optimistic state is on a destroyed component. Move the hook to a stable parent, use startTransition so the transition stays open through the action, and call revalidatePath server-side so fresh props feed back in.
If your dashboard has optimistic UI that will not roll back, or your server action revalidation is not flowing through to the client, that is the kind of thing I fix. See my services. For a related server action issue, see useFormStatus pending stuck after server action.
