useOptimistic shows the result of a click at once, while the server is still working. Learn how it falls back when the server says no, lists with a Sending label, and when not to use it.
In Part 13 every request had a waiting time. While the fake server worked, the page said “Loading…” or “Sending…”. The user, the person using the app, waited.
In Part 29 you met Actions: functions that React runs inside a Transition. React knows when an Action starts and when it ends. This part uses that.
Some answers are easy to guess. When you press Like, the server will almost always say yes. So why make the user wait for it? useOptimistic lets you show the result at once. If the server says no, React goes back to the real value, and you show an error.
We’ll build a like button, then a list of chat messages with a “Sending…” label. We’ll make the fake server fail on purpose, send several messages one after another, and use useOptimistic with useActionState. Then we’ll see when not to use it.
Try this first
Read this code. Don’t press Run yet.
import { startTransition, useOptimistic, useState } from 'react'
// A fake server. It takes 1000 ms to save a like.
function saveLike(liked: boolean): Promise<boolean> {
return new Promise(resolve => {
setTimeout(() => resolve(liked), 1000)
})
}
export default function App() {
const [liked, setLiked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() {
startTransition(async () => {
setOptimisticLiked(true)
const saved = await saveLike(true)
startTransition(() => {
setLiked(saved)
})
})
}
return (
<div>
<button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
<p>The server says: {liked ? 'liked' : 'not liked yet'}</p>
</div>
)
}
As in Part 13, saveLike pretends to be a server. It waits one second, then answers.
Make a guess. You click the button once. Right after the click, what does the button say? And what does the line under it say?
Now press Run and click Like.
The button says “Liked” at once. But for one second, the line under it still says “not liked yet”. Then the server answers, and the line changes to “liked”. The button showed the result before the server answered.
What “optimistic” means
To be optimistic means to expect that things will go well. An optimistic update shows what will probably happen, before you know for sure. If it turns out wrong, the page goes back.
The button above uses two values:
likedis the real state. It changes only when the server answers.optimisticLikedis what the page shows. It can run ahead of the real state for a while.
An everyday example
You put a letter in a post box. You walk away as if it is already sent. You don’t stand there until it arrives. If the letter comes back to you, you know it failed, and you try again.
Walking away is the optimistic update. The letter coming back is the server saying no.
The exact version
In React, nothing has to bring the guess back. React shows the optimistic value only while an Action is running. When the Action ends, the page shows the real state again. If the server said yes and your code changed the real state, the page keeps the new value. If not, the guess just goes away.
The parts of useOptimistic
import { useOptimistic, useState } from 'react'
function LikeButton() {
const [liked, setLiked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
return <p>{optimisticLiked ? 'Liked' : 'Like'}</p>
}
useOptimistic(liked) gives back an array with two things, like useState does:
optimisticLiked, the value to show. React’s docs say it “is equal to value unless an Action is pending”. Pending means “started but not finished”. So while no Action runs, it’s the same asliked.setOptimisticLiked, a function that sets the optimistic value. It works only during an Action.
The value you pass in, liked, is usually state, or a prop from a parent. React’s docs call it the value “returned when there are no pending Actions”.
The Action around it
In “Try this first”, the click handler doesn’t call setOptimisticLiked by itself. It calls it inside startTransition(async () => { ... }). React’s docs call a function passed to startTransition an Action, and it can be async. Part 29 covered Actions and Transitions.
Part 29 got startTransition from the useTransition Hook. Here we import it straight from react. This one is not a Hook, so you can call it anywhere. React’s docs say it works like the one from useTransition, but it gives you no isPending flag. There is one more difference, about errors. We’ll come to it in Why the try and catch.
The Action does three things:
setOptimisticLiked(true)shows “Liked” at once.await saveLike(true)waits for the server. The Action is pending all this time.setLiked(saved)changes the real state to what the server answered.
Why is setLiked wrapped in a second startTransition? React’s docs say that set functions after an await “currently require wrapping” in another startTransition. Without it, React doesn’t treat them as part of the Transition. We tried it without the inner startTransition, with Strict Mode off. The page looked the same here, but React rendered one extra time at the end. With it, the new real value and the end of the Action came in one render.
Watch it, render by render
This is the same button, with three console.log lines added. One runs on every render. The other two run in the Action.
import { startTransition, useOptimistic, useState } from 'react'
// A fake server. It takes 1000 ms to save a like.
function saveLike(liked: boolean): Promise<boolean> {
return new Promise(resolve => {
setTimeout(() => resolve(liked), 1000)
})
}
export default function App() {
const [liked, setLiked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
console.log('render: optimistic', optimisticLiked, '| real', liked)
function handleClick() {
startTransition(async () => {
setOptimisticLiked(true)
console.log('asked the server')
const saved = await saveLike(true)
console.log('the server answered', saved)
startTransition(() => {
setLiked(saved)
})
})
}
return (
<div>
<button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
<p>The server says: {liked ? 'liked' : 'not liked yet'}</p>
</div>
)
}
render: optimistic false | real false
render: optimistic false | real false
asked the server
render: optimistic true | real false
render: optimistic true | real false
the server answered true
render: optimistic true | real true
render: optimistic true | real true
Press Run, click Like once, and wait a second. Each “render” line appears twice. That is Strict Mode: in development, React calls each component twice to find bugs, as Part 2 showed. The two lines from the Action are not doubled, because React runs a click handler only once. With Strict Mode off, we counted three renders. The first is at the start, and the second shows the optimistic value. The third comes when the server answers.
Notice that “asked the server” comes before the optimistic render. React doesn’t stop your code in the middle to render. It renders after your click code has run, as Part 4 showed for batching.
Now the same steps as a picture. You can also see what happens when the server says no.
A like button with useOptimistic. Pick a scenario, then press play, or step through it.
Here are both cases in words.
- Before the click,
likedandoptimisticLikedare bothfalse. The page shows “Like”. - You click.
setOptimisticLiked(true)makes the optimistic valuetrue, and the page shows “Liked”. The real state is stillfalse. - The server is working. The Action is pending, so the page keeps showing “Liked”.
- If the server says yes, our code calls
setLiked(true). React renders once more, with both valuestrue. “Liked” is now real. - If the server says no,
likedstaysfalse, and our code sets an error message. When the Action ends, the page shows the real value again: “Like”, with the error.
React’s docs say there is “no extra render” to clear the optimistic value. The new real value and the end of the Action come in one render.
It must run inside an Action
What if you call setOptimisticLiked in a plain click handler, with no startTransition?
import { useOptimistic, useState } from 'react'
// A fake server. It takes 1000 ms to save a like.
function saveLike(liked: boolean): Promise<boolean> {
return new Promise(resolve => {
setTimeout(() => resolve(liked), 1000)
})
}
export default function App() {
const [liked, setLiked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
async function handleClick() {
setOptimisticLiked(true)
const saved = await saveLike(true)
setLiked(saved)
}
return (
<div>
<button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
<p>The server says: {liked ? 'liked' : 'not liked yet'}</p>
</div>
)
}
Run it and click Like. In development, React prints this error in the Console:
An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.
And the button doesn’t stay changed. React put “Liked” on the page and took it away again at once, before the server answered. You’ll see at most a very short flash. In our test in Chromium, “Liked” was on the screen for about 17 ms, one frame. Then, a second later, the real state changed, and “Liked” came back for good.
So the optimistic value had nothing to hold it. React’s docs say the same: without a Transition, the optimistic value appears for a moment and then goes back. The Action is what keeps the optimistic value on the page while you wait.
There are two ways to be inside an Action:
- Wrap your code in
startTransition, as in “Try this first”. - Use a function that React already runs as an Action, like a form’s
actionprop from Part 29. You’ll see one below.
When the server says no
A real server can fail. This fake server starts out broken. As in Part 13, the “Fix the server” button changes a normal variable. So the page doesn’t change when you click it.
import { startTransition, useOptimistic, useState } from 'react'
// A fake server that starts out broken. It takes 1000 ms.
let serverIsDown = true
function saveLike(liked: boolean): Promise<boolean> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (serverIsDown) {
reject(new Error('The server is down'))
} else {
resolve(liked)
}
}, 1000)
})
}
export default function App() {
const [liked, setLiked] = useState(false)
const [error, setError] = useState('')
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() {
setError('')
startTransition(async () => {
setOptimisticLiked(true)
try {
const saved = await saveLike(true)
startTransition(() => {
setLiked(saved)
})
} catch (e) {
startTransition(() => {
setError('Your like was not saved. ' + String(e))
})
}
})
}
return (
<div>
<button onClick={() => { serverIsDown = false }}>Fix the server</button>
<button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
{error && <p>{error}</p>}
</div>
)
}
Run it and click Like. The button says “Liked” for one second. Then it goes back to “Like”, and this line appears: “Your like was not saved. Error: The server is down”.
Now click Fix the server, then Like again. This time “Liked” stays.
Look at what the code does when the server fails. It never sets the button back to “Like”. There is no code to undo the like. The Action ended, liked was still false, so the page showed false. React’s blog says that when the update “finishes or errors”, React “will automatically switch back”.
What the code must do is tell the user. Without the error line, the like would just disappear, and the user wouldn’t know why.
Why the try and catch
try and catch come from Part 13. We tried the example without them. The like still went away, and no code caught the error. React’s docs say that for the startTransition you import from react, React reports the error with reportError. That is the browser’s own way to report an error that no code caught.
In a real app, the user sees nothing on the page. The error goes only to the Console. This playground is different: it shows such errors in its own red box. In our test in Chromium, the box said Uncaught Error: The server is down.
Part 29 showed that a thrown error goes to the nearest error boundary, a component that catches errors. That is true for the startTransition from useTransition, and for a form’s action. It is not true for the one you import from react. Part 32 covers error boundaries. With no boundary, React removes the whole app from the page, as Part 29 found. For a small error like a lost like, a message next to the button is much kinder.
A list: messages with a “Sending…” label
A like is one value. A chat is a list. When you send a message, it should appear at once, with a small “Sending…” label. When the server saves it, the label goes away.
For a list, useOptimistic takes a second argument: an update function. React’s docs call it a reducer, like the one in Part 15. It gets the current list and the new message, and gives back the list to show.
This form uses an action prop, as in Part 29. React runs that function as an Action, so we don’t need startTransition around addOptimisticMessage.
import { startTransition, useOptimistic, useRef, useState } from 'react'
type Message = { id: number; text: string; sending: boolean }
// A fake server. It takes 1000 ms to save a message.
// The "Break the server" button makes it fail.
let serverIsDown = false
function saveMessage(text: string): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (serverIsDown) {
reject(new Error('The server is down'))
} else {
resolve(text)
}
}, 1000)
})
}
let nextId = 1
export default function App() {
const [messages, setMessages] = useState<Message[]>([])
const [error, setError] = useState('')
const formRef = useRef<HTMLFormElement>(null)
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(current: Message[], newMessage: Message) => [...current, newMessage]
)
async function sendAction(formData: FormData) {
const text = String(formData.get('message'))
const id = nextId++
setError('')
addOptimisticMessage({ id, text, sending: true })
formRef.current?.reset()
try {
const saved = await saveMessage(text)
startTransition(() => {
setMessages(current => [...current, { id, text: saved, sending: false }])
})
} catch (e) {
startTransition(() => {
setError('"' + text + '" was not sent. ' + String(e))
})
}
}
return (
<div>
<ul>
{optimisticMessages.map(m => (
<li key={m.id}>
{m.text} {m.sending && <small>Sending...</small>}
</li>
))}
</ul>
{error && <p>{error}</p>}
<form action={sendAction} ref={formRef}>
<input name="message" aria-label="Message" />
<button type="submit">Send</button>
</form>
<button onClick={() => { serverIsDown = true }}>Break the server</button>
</div>
)
}
Run it. Type “Hi” and click Send. “Hi” appears at once, with “Sending…” after it. A second later, the label goes away.
Now click Break the server, type “Bye” and click Send. “Bye” appears with “Sending…” too. A second later, it disappears, and the error line says it was not sent.
How the list works
addOptimisticMessage(...)doesn’t set the list. It passes one message to the update function. The update function adds it to the end:[...current, newMessage].- The optimistic copy has
sending: true. The real copy, added bysetMessages, hassending: false. That one field is how the page knows which messages are only a guess. React’s docs use a flag like this too, so “you can show loading state for individual items”. - Each message gets an
idfromnextId. The optimistic copy and the real copy use the sameid, so they have the samekey(Part 8). - The update function must be pure: it only builds a new list from what it gets, and changes nothing else. React’s docs say it “must be pure”. React may call it more than once for one message, so it must not change anything outside.
Why the box is cleared in the code
The box is uncontrolled: its text lives in the page, not in React state (Part 28). To reset a box here means to clear its text. Part 29 showed that React does this only after the action is done.
For a chat, that is too late. We tried the example without the reset() line, typing key by key. After “One” was sent, the box still said “One”. Typing “Two” then made OneTwo, and the server saved OneTwo.
So sendAction clears the box at once, with formRef.current?.reset(). formRef is a ref to the <form> tag (Part 14), and reset() is the browser’s own function that clears a form. React’s docs do the same in their own chat example.
When the server fails, the text is already gone from the box. That is why our error message repeats it: "Bye" was not sent.
Sending several messages, one after another
In the chat example, send three messages quickly. Type “One” and click Send. Then type “Two” and send it, then “Three”.
Each one appears at once, with “Sending…”. We tried it, typing each word key by key. Counting from when the page loaded, we sent at 0.3, 0.6 and 1.2 seconds. The server saved “One” at 1.3 seconds and “Two” at 1.6 seconds. But all three kept “Sending…” until 2.2 seconds, when “Three” was saved. Then all three labels went away together.
Why didn’t “One” lose its label first? Each send is its own Action, and here the three Actions ran at the same time. React’s docs say that when several Transitions are running, React “currently” handles them together, as one group. So React waited until every Action had ended. Then it showed the real list in one render. The word “currently” means this may change in a later version of React.
This is not the batching from Part 4. Part 4 found that React handles each click on its own. Here, too, each click got its own render with its own “Sending…” message. Only the real list waited, because the Transitions ran at the same time. A message sent after the others had all ended would get its own render.
For a chat, that’s fine. The messages show at once, and none of them is lost.
With useActionState
Part 29 covered useActionState. It keeps the result of the last Action as state. Part 29 named its second value signAction. Here we use React’s own name for it, dispatchAction. Here is the chat again, with useActionState holding the messages and the error. sendAction is now outside the component. It gets the previous state and one message, and gives back the next state.
import { useActionState, useOptimistic, useRef } from 'react'
type Message = { id: number; text: string; sending: boolean }
type ChatState = { messages: Message[]; error: string }
// A fake server. It takes 1000 ms to save a message.
// The "Break the server" button makes it fail.
let serverIsDown = false
function saveMessage(text: string): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (serverIsDown) {
reject(new Error('The server is down'))
} else {
resolve(text)
}
}, 1000)
})
}
async function sendAction(previous: ChatState, message: Message): Promise<ChatState> {
try {
const saved = await saveMessage(message.text)
return { messages: [...previous.messages, { ...message, text: saved, sending: false }], error: '' }
} catch (e) {
return { messages: previous.messages, error: '"' + message.text + '" was not sent. ' + String(e) }
}
}
let nextId = 1
export default function App() {
const [state, dispatchAction] = useActionState(sendAction, { messages: [], error: '' })
const formRef = useRef<HTMLFormElement>(null)
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
state.messages,
(current: Message[], newMessage: Message) => [...current, newMessage]
)
function submitAction(formData: FormData) {
const message = { id: nextId++, text: String(formData.get('message')), sending: true }
addOptimisticMessage(message)
formRef.current?.reset()
dispatchAction(message)
}
return (
<div>
<ul>
{optimisticMessages.map(m => (
<li key={m.id}>
{m.text} {m.sending && <small>Sending...</small>}
</li>
))}
</ul>
{state.error && <p>{state.error}</p>}
<form action={submitAction} ref={formRef}>
<input name="message" aria-label="Message" />
<button type="submit">Send</button>
</form>
<button onClick={() => { serverIsDown = true }}>Break the server</button>
</div>
)
}
It works like the first chat. But look at what changed:
- There is no
startTransitionand nosetMessages. The Action’s return value becomes the new state. React’s docs show this same pair,useActionStatewithuseOptimistic, for “immediate UI feedback”. - On failure,
sendActionreturns the old list and an error. The optimistic “Bye” goes away, because the real list never had it. submitActionis the form’s Action. It shows the message first, withaddOptimisticMessage, clears the box, and then callsdispatchAction.- Keep the
dispatchActioncall inside the form’s Action. Part 29 showed that calling it outside a Transition makes React print a message about it.
Why not call addOptimisticMessage inside sendAction? Because useActionState runs its Actions one at a time. React’s docs say it has to run the calls in order, one after another. So a second message waits for the first one to finish before its Action even starts. We tried it: we sent “Two” about 0.2 seconds after “One”. “Two” didn’t appear until the server answered “One”, about one second after the first send. Calling addOptimisticMessage first shows every message at once.
Send three quickly here too. Each one shows at once. But useActionState asks the server one at a time. In our test, we sent at 0.3, 0.6 and 1.2 seconds. The server saved them at 1.3, 2.3 and 3.3 seconds. The labels stayed until the last one.
When not to use it
The optimistic value is a guess shown as if it were true. For a like, a wrong guess costs little. The like goes away, and the user sees why.
React’s docs use useOptimistic for likes, names, follow buttons, to-do lists, a shopping cart and chat messages. All of these are easy to show again, or to try again.
Now think about paying for something. Say the page shows “Paid!” at once, and the user closes it. Then the server says no. The user thinks they paid, but they didn’t. One developer guide on optimistic updates lists payments as a place not to use them. It says payments need clear confirmation.
Here is a simple test before you use it. Ask: if this guess is wrong, will the user be hurt by having seen it for a second? If yes, wait for the server, and show “Sending…” or “Paying…” while you wait, as Part 13 did.
Common mistakes
Calling the optimistic setter outside an Action
You saw this one in It must run inside an Action. React prints the error “An optimistic state update occurred outside a transition or action”, and the optimistic value doesn’t stay.
The fix: call it inside startTransition, or inside a function React already runs as an Action, like a form’s action.
Never updating the real state
import { startTransition, useOptimistic, useState } from 'react'
// A fake server. It takes 1000 ms to save a like.
function saveLike(liked: boolean): Promise<boolean> {
return new Promise(resolve => {
setTimeout(() => resolve(liked), 1000)
})
}
export default function App() {
const [liked, setLiked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() {
startTransition(async () => {
setOptimisticLiked(true)
await saveLike(true)
})
}
return (
<div>
<button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
<p>The server says: {liked ? 'liked' : 'not liked yet'}</p>
</div>
)
}
Run it and click Like. “Liked” shows for one second. Then it goes back to “Like”, even though the server saved the like.
The optimistic value lasts only as long as the Action. Here nothing changes liked, so when the Action ends, the page shows false again. The fix: when the server says yes, set the real state, as “Try this first” does with setLiked(saved).
Showing a guess as if it were final
import { startTransition, useOptimistic, useState } from 'react'
// A fake server. It takes 1000 ms to save a note.
function saveNote(text: string): Promise<string> {
return new Promise(resolve => {
setTimeout(() => resolve(text), 1000)
})
}
export default function App() {
const [note, setNote] = useState('Buy milk')
const [optimisticNote, setOptimisticNote] = useOptimistic(note)
function handleClick() {
startTransition(async () => {
setOptimisticNote('Buy bread')
const saved = await saveNote('Buy bread')
startTransition(() => {
setNote(saved)
})
})
}
return (
<div>
<p>{optimisticNote} (saved!)</p>
<button onClick={handleClick}>Change the note</button>
</div>
)
}
Run it and click the button. The page says “Buy bread (saved!)” at once. But for one second, that isn’t true yet. If the server failed, the user would have seen a lie.
Mark what is still a guess. In a list, use a field like sending. For one value, compare the two values. React’s docs say: “If the values are not equal, there’s a Transition in progress.” Press Edit. Add the isSaving line just after useOptimistic, and change the <p> line, like this:
declare const note: string
declare const optimisticNote: string
const isSaving = optimisticNote !== note
const line = <p>{optimisticNote} {isSaving ? '(saving...)' : '(saved)'}</p>
The two declare const lines only tell TypeScript that these values exist, as declare function did in Part 13.
Now the click shows “Buy bread (saving…)” for one second, then “Buy bread (saved)”. In our test, that is what the page showed.
No error message when it fails
import { startTransition, useOptimistic, useState } from 'react'
declare function saveLike(liked: boolean): Promise<boolean>
function LikeButton() {
const [liked, setLiked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() {
startTransition(async () => {
setOptimisticLiked(true)
try {
const saved = await saveLike(true)
startTransition(() => {
setLiked(saved)
})
} catch {
// Nothing here.
}
})
}
return <button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
}
declare function tells TypeScript that saveLike exists somewhere else, as in Part 13.
When the server fails, the like just disappears. We tried it with the broken server: the page went from “Liked” back to “Like”, and nothing else changed. The user may think they never clicked. The fix: set an error message in the catch, as in When the server says no.
Practice
Use the examples above. Press Edit, change the code, and press Run.
- In “Try this first”, make the fake server take 3000 ms. Click Like. What does the button say for those three seconds? And the line under it?
- In the “When the server says no” example, add
console.log('render:', optimisticLiked, liked)just beforefunction handleClick. Run it and click Like once, with the server still broken. Wait two seconds. How many lines does the Console show, and what are they? - Make “Try this first” a button that can also unlike. Use
const next = !optimisticLikedinside the Action, and savenext. Show “Unlike” when the optimistic value istrue. Then click it twice quickly. What do the button and the line show at the end? - In the first chat example, click Break the server, type “Bye” and click Send. When the error appears, what is in the text box? Why does the error message repeat your text?
Answers
- The button says “Liked” for all three seconds. The line says “The server says: not liked yet” until the server answers. Then it says “liked”. In our test, at 2.8 seconds the line still said “not liked yet”. At 3.2 seconds it said “liked”.
- Six lines. There are three renders: before the click, the optimistic one, and the one after the failure. Strict Mode doubles each one. You see
render: false falsetwice, thenrender: true falsetwice, thenrender: false falsetwice. - Inside the Action, write
const next = !optimisticLiked, thensetOptimisticLiked(next)andconst saved = await saveLike(next). Change the label to{optimisticLiked ? 'Unlike' : 'Like'}. The first click shows “Unlike” at once, and the second click shows “Like” at once. A second later, the button still says “Like”, and the line still says “not liked yet”. The server savedtrueand thenfalse. In our test, the page never showed “liked” in between. React waited for both Actions before it showed the real state.
But that only works because both answers took the same time. A real server can answer out of order, as in Part 13. We made saving true take 1500 ms and saving false take 500 ms. Then the last answer was true. The page ended on “Unlike” and “liked”, the opposite of the user’s last click. React’s docs say updates in Transitions can come out of order. They call it a known problem that they plan to fix.
4. The box is empty. Our code cleared it with formRef.current?.reset() when you clicked Send, before the server answered. The text is gone from the box, so the message repeats it, and the user can type it again.
Interview questions
Try to answer each one out loud before you open the answer.
What is an optimistic update, and which hook helps you make one?
An optimistic update shows the result of something before the server confirms it. The page assumes the request will work. useOptimistic(value) gives back a value to show and a function to set it. While an Action is pending, the value is whatever you set. When no Action is pending, it’s value, the real state or prop.
A strong answer adds that there’s no separate “undo” step. When the Action ends, React renders with the real value. If your code changed the real state, the page keeps it. If not, the guess disappears.
Why must you call the optimistic setter inside a Transition or Action?
The optimistic value lasts only while an Action is pending. Outside one, nothing holds it. In development, React 19.3 prints this error in the Console: “An optimistic state update occurred outside a transition or action”. It shows the value and takes it away again at once. In our test, that happened before the server answered.
A strong answer names the two ways in. One is startTransition(async () => { ... }). The other is a function that React already runs as an Action, like a form’s action prop.
What happens when the request fails?
The Action ends, and the real state is still the same as before. So the page shows the real value again. The optimistic item disappears with no undo code. But you still have to catch the error and show a message. Otherwise the user just sees their change disappear.
A strong answer knows where an error goes when no code catches it. With the startTransition from react, React reports it with reportError, as an error nobody caught. With the one from useTransition, or a form action, it goes to the nearest error boundary.
When do you pass an update function as the second argument?
When the optimistic value is built from the current one, as with a list. useOptimistic(messages, (current, newMessage) => [...current, newMessage]) lets you call addOptimisticMessage(message) with just the new item. React’s docs call this function a reducer. It must be pure.
A strong answer adds why it helps. Say the real list changes while the Action is pending. React’s docs say React then runs the reducer again, on the new list. So your new item is added to the latest list, not an old copy.
How can the page tell which items are still a guess?
Give the optimistic copy a flag, such as sending: true, and show a label like “Sending…” for it. For a single value, compare it with the real one: if optimisticNote !== note, an Action is still pending.
A strong answer adds that useTransition also gives an isPending flag. React’s docs say that flag is built on useOptimistic, so it works the same way as comparing the two values.
When the server answers, why wrap the set function in a second Transition?
React’s docs say set functions after an await “currently require wrapping” in another startTransition. Without it, React doesn’t treat them as part of the Transition. With it, the new real value and the end of the Action come in one render.
A strong answer says this is a known limit, which React’s docs say they plan to fix. It also knows that the Action’s return value in useActionState needs no wrapping. And it warns that answers can still come back out of order. With two quick clicks on a like button, the slower answer wins, even if it was the first click.
When would you not show an optimistic update?
When a wrong guess can hurt the user, like a payment. If the page says “Paid!” and the server later says no, the user may already have left. For those, wait for the server and show a pending label.
A strong answer gives a test. If the guess is wrong for a second, is that cheap? Is it easy to try again? Likes, chat messages and cart changes pass. Payments don’t.
Sources
- useOptimistic, react.dev: the two return values, “is equal to value unless an Action is pending”, the reducer, “must be pure”, the troubleshooting section on calls outside a Transition, “There’s no extra render”, what happens when the Action fails, the
pendingflag, and comparing the two values. - useTransition, react.dev: Actions, “currently require wrapping” after
await, batching of ongoing Transitions, errors and error boundaries, andisPending. - startTransition, react.dev: “React reports the error with reportError”.
- useActionState, react.dev: using it with
useOptimistic, and Actions that run in order. - form, react.dev: the
actionprop runs in a Transition, the form resets after the action succeeds, and the “Sending…” message example. - React v19, React blog:
useOptimistic, and that React will “automatically switch back” when the update “finishes or errors”. - How Optimistic Updates Make Apps Feel Faster, OpenReplay blog: when not to use optimistic updates, with payments on that list.
- The error text, the render logs, the page changes, the times and the counts in this part come from running React 19.3.0 for this post, in jsdom and in headless Chromium.