useOptimistic
Shows a temporary expected state while an async Action is pending, then falls back to the real value.
const [optimisticState, setOptimistic] = useOptimistic(value, reducer?);- Added in React 19.0.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$value | S | The value returned when there are no pending Actions. This is your real, settled state — typically props or a useState value that the server or a parent eventually confirms. |
$reduceroptional | (currentState: S, action: A) => S | The reducer function that specifies how the optimistic state gets updated. It must be pure, takes the current state and the value passed to the set function, and returns the next optimistic state. Without it, the value passed to the set function becomes the optimistic state directly. |
Return value
[optimisticState: S, setOptimistic: (optimisticState: A | ((pending: S) => S)) => void]An array with exactly two values: optimisticState — equal to value unless an Action is pending, in which case it is the state returned by reducer (or the value passed to the set function when no reducer was given) — and the set function, which updates the optimistic state for the duration of an Action.
Errors
Nothing, but calling the set function outside an Action logs a warning and the optimistic value renders briefly before reverting.
Changelog
- PHP 19.0.0Released as stable after a period in React Canary; requires React 19 or later.
useOptimistic exists for one problem: an Action takes 300 ms and the UI looks broken for those 300 ms. You show the result you expect immediately, and React discards it automatically once the Action settles — success or failure.
When to reach for it
- Appending a message, comment or todo that the server will confirm.
- A like, star or vote toggle that should flip the instant it is clicked.
- Reordering or deleting a row where waiting for a round trip feels laggy.
What trips people up
The set function must be called inside an Action — inside the async function you passed to startTransition, inside a <form action>, or inside an action passed to useActionState. Called from a bare click handler it warns, flashes and reverts.
The rollback is not error handling. When the request fails, React simply stops showing the optimistic value and renders value again — the row vanishes with no explanation. You still need to catch the failure and tell the user.
Finally, value has to actually update on success. If the real state never changes, the optimistic row disappears when the Action completes and the user sees their work undone.
Example
When to use it
- Append a chat message to the thread the moment Send is pressed.
- Flip a like button and its count before the mutation returns.
- Remove a row from a table while the delete request is in flight.
- Show a renamed file label immediately while the rename Action runs.
Frequently asked questions
Why does my optimistic value flash and disappear right away?
The set function was called outside an Action. It only holds while a transition or form action is pending, so calling it from a plain onClick lets the update commit and revert on the very next render. Wrap the work in startTransition(async () => { ... }) and call the setter inside that callback, or trigger it from a form action.
Does useOptimistic roll back automatically when the request fails?
Yes — when the Action finishes, successfully or not, React stops rendering the optimistic state and shows the real value again. It does not surface the error for you, so catch the rejection and render a message; otherwise the user watches their change silently undo itself.
Can I use useOptimistic without a reducer?
Yes. Omit the second argument and the value you pass to the set function becomes the optimistic state directly, which suits toggles and single values. Pass a reducer when the optimistic state is derived from the current one — appending to a list, incrementing a count, filtering a row out.
More examples
Optimistic toggle without a reducer
The label flips to "Liked" on the first paint after the click, roughly 600ms before persistLike resolves. Because no reducer was passed, the boolean handed to setOptimisticLiked becomes the optimistic state directly. Once setLiked runs, optimisticLiked and liked agree and nothing visibly changes.
import { useOptimistic, useState, useTransition } from "react";
function persistLike(next) {
return new Promise((resolve) => setTimeout(() => resolve(next), 600));
}
export default function LikeButton() {
const [liked, setLiked] = useState(false);
const [, startTransition] = useTransition();
// With no reducer, whatever you pass to the setter IS the optimistic state.
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked);
function toggle() {
startTransition(async () => {
const next = !liked;
setOptimisticLiked(next);
const confirmed = await persistLike(next);
setLiked(confirmed);
});
}
return (
<button onClick={toggle} aria-pressed={optimisticLiked}>
{optimisticLiked ? "Liked" : "Like"}
</button>
);
}Rolling back and reporting the failure
Clicking Delete removes the row instantly; 500ms later the promise rejects, React drops the optimistic state and the row returns, now accompanied by "Row 1 is locked". This is the pattern to copy: the rollback is automatic, the explanation is not.
import { useOptimistic, useState, useTransition } from "react";
function deleteRow(id) {
return new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error("Row " + id + " is locked")), 500)
);
}
const ROWS = [
{ id: 1, name: "invoices.csv" },
{ id: 2, name: "payroll.csv" },
];
export default function FileTable() {
const [rows, setRows] = useState(ROWS);
const [error, setError] = useState(null);
const [, startTransition] = useTransition();
const [optimisticRows, removeOptimistic] = useOptimistic(rows, (current, id) =>
current.filter((row) => row.id !== id)
);
function remove(id) {
startTransition(async () => {
setError(null);
removeOptimistic(id);
try {
await deleteRow(id);
setRows((prev) => prev.filter((row) => row.id !== id));
} catch (e) {
// React already restores the row. Without this message the user
// just sees it reappear and assumes the click was ignored.
setError(e.message);
}
});
}
return (
<div>
{error ? <p role="alert">{error}</p> : null}
<ul>
{optimisticRows.map((row) => (
<li key={row.id}>
{row.name} <button onClick={() => remove(row.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
Discussion