useOptimistic()
Renders a predicted state while an action is in flight, then falls back to the real value.
const [optimisticState, setOptimistic] = useOptimistic(value, reducer?)- Added in Next.js v14.0.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$value | State | The committed value returned when no Action is pending. |
$reduceroptional | (currentState: State, action: Payload) => State | Specifies how the optimistic state is derived. Must be pure: it takes the current state and the payload passed to the set function and returns the next optimistic state. |
Return value
[optimisticState: State, setOptimistic: (payload: Payload) => void]An array of two values: the current optimistic state, equal to value unless an Action is pending, in which case it is the reducer's result (or the value passed to the set function when no reducer is given); and the set function, which may only be called inside an Action.
Changelog
- PHP v15.0.0Stable in React 19. Earlier Next.js versions shipped it from the React canary channel.
useOptimistic shows the user the result they are about to get. While an Action runs, the hook returns the reducer's prediction instead of the committed value; the moment the Action settles, the prediction is thrown away and value takes over again.
When to reach for it
- A chat or comment thread where a sent message should appear immediately.
- A like or star toggle that should flip on click, not on response.
- Any list where a round trip to the server is long enough to feel like lag.
What trips people up
The set function must be called inside an Action — a form's action prop or a startTransition callback. Called anywhere else, React warns and the optimistic value flashes for one render before reverting.
The bigger trap is what happens after: the prediction disappears when the Action settles, so if the action did not actually update the source of value — no revalidatePath, no updateTag, no refreshed prop — the new item vanishes from the screen and reappears on the next navigation. It looks like the hook failed; it is the mutation that never invalidated its cache. There is no automatic rollback UI either: a rejected action just restores value, so surface the failure yourself.
Example
When to use it
- Append a chat message to the thread the instant Send is pressed.
- Flip a like button's filled state before the server confirms the write.
- Mark a to-do complete and grey it out while the mutation is running.
Frequently asked questions
Why does my optimistic update disappear right after the action finishes?
The prediction is dropped when the Action settles and the committed `value` takes over, so if the action did not revalidate the data behind `value` the old state comes back. Call revalidatePath, updateTag, or refresh inside the Server Action.
Do I need to roll back manually when the action fails?
No — the optimistic state is discarded automatically and the committed value returns, so the UI reverts on its own. What is not automatic is telling the user, so keep an error in ordinary state if the failure needs a message.
Can I use useOptimistic without a form?
Yes — call the set function inside a startTransition callback from any event handler. The only requirement is that the call happens inside an Action, not that it comes from a form submission.
More examples
A message thread
The `formAction` passed to `<form action>` is itself an Action, so calling `addOptimisticMessage` inside it is legal and the message renders before `send` resolves. For the message to stay on screen afterwards, `send` must revalidate whatever produces the `messages` prop.
// app/thread.tsx
'use client'
import { useOptimistic } from 'react'
import { send } from './actions'
type Message = { message: string }
export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<
Message[],
string
>(messages, (state, newMessage) => [...state, { message: newMessage }])
const formAction = async (formData: FormData) => {
const message = formData.get('message') as string
addOptimisticMessage(message)
await send(message)
}
return (
<div>
{optimisticMessages.map((m, i) => (
<div key={i}>{m.message}</div>
))}
<form action={formAction}>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
)
}Marking the pending item and handling failure
The reducer adds a `pending` marker the committed data never carries, which is how you style the in-flight row differently from a saved one. Rollback is automatic — the row reverts when the action settles — but the error message is not, so it is tracked in ordinary state.
'use client'
import { useOptimistic, useState, startTransition } from 'react'
import { toggleTodo } from './actions'
type Todo = { id: string; title: string; done: boolean; pending?: boolean }
export function TodoList({ todos }: { todos: Todo[] }) {
const [error, setError] = useState<string | null>(null)
const [optimisticTodos, setOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], id: string) =>
state.map((t) =>
t.id === id ? { ...t, done: !t.done, pending: true } : t
)
)
function onToggle(id: string) {
startTransition(async () => {
setOptimisticTodo(id)
const result = await toggleTodo(id)
if (!result.ok) setError('Could not save. Try again.')
})
}
return (
<ul>
{error ? <li aria-live="polite">{error}</li> : null}
{optimisticTodos.map((t) => (
<li key={t.id} style={undefined}>
<button onClick={() => onToggle(t.id)} disabled={t.pending}>
{t.done ? 'Undo' : 'Done'}
</button>
{t.title}
{t.pending ? ' …' : ''}
</li>
))}
</ul>
)
}Calling the setter outside an Action
Without a surrounding Action, React has no transition to scope the optimistic value to, so it is discarded on the next render instead of persisting for the duration of the await. Wrapping the body in `startTransition` — or moving it to a form's `action` prop — is the fix.
'use client'
import { useOptimistic } from 'react'
import { save } from './actions'
export function Broken({ count }: { count: number }) {
const [optimisticCount, setOptimisticCount] = useOptimistic(count)
async function onClick() {
// Warning: "An optimistic state update occurred outside a transition."
// optimisticCount flashes for one render, then snaps back to `count`.
setOptimisticCount(count + 1)
await save(count + 1)
}
return <button onClick={onClick}>{optimisticCount}</button>
}
Discussion