useOptimistic: Implementing Optimistic Updates Without useState

Chris is developing a chat app.
When the "Send" button is clicked, the message flies to the server, and once saved in the DB, it appears in the chat window.
However, even if the network is slightly slow, the user has to stare blankly for 1โ2 seconds after pressing the button.
"It's so frustrating! KakaoTalk or Instagram show messages immediately when pressed."
This is called Optimistic UI. It is a technique where we assume "it will definitely succeed" and update the UI first, before confirming success from the server.
To implement this, Chris wrote code in the traditional way.
// โ Chris's manual implementation (Complex)
function ChatApp({ messages }) {
// Must manage local state separately from server data.
const [optimisticMessages, setOptimisticMessages] = useState(messages);
const sendMessage = async (text) => {
// 1. Add fake message to list first (Immediate reaction)
const tempMsg = { id: Date.now(), text, sending: true };
setOptimisticMessages((prev) => [...prev, tempMsg]);
try {
// 2. Server request
await api.sendMessage(text);
// 3. If successful, re-fetch latest list from server to sync (Refresh)
} catch (err) {
// 4. ๐ฅ If failed? Must find and remove the fake message added earlier (Rollback)
setOptimisticMessages((prev) => prev.filter(m => m.id !== tempMsg.id));
alert('Send failed!');
}
};
return <MessageList messages={optimisticMessages} />;
}Implementation is possible, but painful.
React 19's useOptimistic hook automates all this manual management.
1. useOptimistic: The Manager of Temporary State
useOptimistic mediates between "Server Data (Truth)" and "Temporary Data (Lie)".
While an asynchronous task is in progress, it shows the 'optimistic state' we defined. When the task finishes or new data arrives, it automatically discards the temporary state and switches to the server data.
Usage
const [optimisticState, addOptimistic] = useOptimistic(state, reducer);Refactoring: Removing useState and Rollback
Let's change Chris's chat app to use useOptimistic.
// ChatApp.tsx
import { useOptimistic, useState } from 'react';
import { sendMessageAction } from './actions';
function ChatApp({ messages }: { messages: Message[] }) {
// โ
Define Optimistic State
// Normally shows messages (server data),
// but shows newMessage appended to the end when addOptimistic is called.
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(currentMessages, newMessage: string) => [
...currentMessages,
{ id: Date.now(), text: newMessage, sending: true }
]
);
const formAction = async (formData: FormData) => {
const text = formData.get('message') as string;
// 1. Update UI Immediately! (Don't wait for server response)
addOptimistic(text);
// 2. Actual server request (Background)
await sendMessageAction(text);
// 3. Done!
// When the action finishes, React automatically overwrites optimisticMessages
// with the latest server data (messages). No rollback code needed.
};
return (
<section>
<MessageList messages={optimisticMessages} />
<form action={formAction}>
<input name="message" />
<button type="submit">Send</button>
</form>
</section>
);
}Look at the code. There is no try-catch, and no rollback logic.
React maintains the fake data in optimisticMessages only while the asynchronous action (formAction) is executing. When the action ends, this temporary state is discarded, and it naturally transitions back to the latest props.messages passed down from the parent.
2. How it Works: The Layer Concept
How is this possible? It's easy to understand if you think of useOptimistic like layers in Photoshop.
When the user clicks send, React overlays the Optimistic Layer on top of the Base Layer to show the user.
What happens if the server request fails or completes? React simply removes (makes transparent) the Optimistic Layer.
Then, the Base Layer (server data) becomes visible to the user again.
Thanks to this method, developers don't need to manually command, "It failed, so pop it from the array!"
3. Limitations and Cautions
useOptimistic is powerful, but not a silver bullet.
1. Must be used inside an Async Action
This hook is tied to the lifecycle of async/await. It works as intended when used inside <form action={...}> or useTransition, rather than general click event handlers.
2. Complex logic is still difficult
Adding to a list or changing text is easy. However, if you need to handle conflicts where different users modify the same data simultaneously, or need sophisticated error handling, the onMutate pattern in TanStack Query might offer more granular control.
Key Takeaways
We have mastered React 19's Async Trio (use, useActionState, useOptimistic). Now, form and data handling are conquered.
But what about screen rendering performance? When an input field stutters due to heavy calculations, the Concurrency hooks introduced in React 18 and perfected in 19 appear as saviors.
Continuing in: "useTransition (Async Support) vs useDeferredValue: The Completion of Concurrent Rendering."