React 18 introduced a powerful new hook called useOptimistic, designed to make your apps feel faster and more responsive when dealing with remote data. In this article, we’ll break down what it does, why it’s useful, and how to use it in real applications.
useOptimistic is a React hook that allows you to update the UI immediately while waiting for a server response. This pattern is called an optimistic update, because you assume the server operation will succeed and show the result to the user instantly.
useOptimistic usually used alongside server actions API’s like startTransition or useActionState.
useOptimistic works by displaying temporary “optimistic” state on top of the confirmed state, and React can merge or rollback based on the server’s response.
Let’s see an example in action, imagine a chat app where a user send a message and wait for server to respond until the message appear in the message list:
Without useOptimistic:
- The user type into the input field and click a send button.
- The message is sent to server using ajax request.
- User will wait until message sent and appended in the message list.
- At the time message is sent the user have to wait and new message not displayed until server processes it.
- This pattern although it’s widely used but app may feel unresponsive as user have to wait a unpredictable time until server responds.
With useOptimistic:
- The user type into the input field and click a send button.
- The message is sent to server using ajax request.
- At this time message is already appended to the message list with a “…sending” label, for instance which gives the user impression that the message is on the way.
- When the server finishes successfully the “…sending” label disappear while message still on the message list
- If server responds with failure a “Retry” button is displayed beside the message to try sending it again.
- This pattern is widely used in popular chat apps. This is where
useOptimistic()is used, the optimistic updates where users see the results immediately while server works in background.
The terminology of useOptimistic():
const [optimisticState, addOptimistic] = useOptimistic(state, reducer);
-
optimisticState→ the value you render in JSX -
addOptimistic(next)→ add temporary updates -
reducer(current, next)→ defines how optimistic updates are merged with the base state.
Example: chat app
import React, {useOptimistic, useState, useTransition} from "react";
const Chat = () => {
const [messages, setMessages] = useState([]);
const [isLoading, startTransition] = useTransition();
const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages, (state, newMessage) => {
return [
...state,
{
text: newMessage,
time: new Date(),
status: 'sending'
},
]
});
return (
<div id="chat-panel">
</div>
)
}
In this code i declared the base state “messages” with the useState() hook which hold the chat messages, and passed it to the useOptimistic() hook as first argument. The second argument in useOptimistic() is the reducer function.
The reducer function accepts the and currentStateoptimisticValue and it return a new state the merged value of the currentState and optimisticValue
useOptimistic() return an array of two values, the optimisticState, and addOptimistic the dispatching function we will use to trigger the reducer function:
const [optimisticMessages, addOptimisticMessage] = useOptimistic()
The useTransition() hook is essential here to work with useOptimistic() so that we can wrap the dispatching function addOptimisticMessage() inside of startTransition.
Chat JSX:
This is the chat component render JSX:
return (
<div id="chat-panel">
<h3 className="chat-header">Chat</h3>
<div className="chat-messages">
<ul>
{
optimisticMessages.map((message, index) => {
return (
<li key={index} className={messageClass(message, index)}>
<span className="body">
<span className="author">{authorName(index)}</span>
<span className="content">{message.text} </span>
{
message.status === 'sending' ? <small>sending...</small> :
message.status === 'error' ? <a href="#" className="retry" onClick={(e) => retrySend(e, message, index)}>Retry</a> : null
}
</span>
<time>{timeAgo(message.time)}</time>
</li>
)
})
}
</ul>
</div>
<form onSubmit={handleSubmit}>
<input type="text" name="message" />
<button type="submit" disabled={isLoading}>Send</button>
</form>
</div>
)
We iterated over the optimisticMessages coming from useOptimistic() not the base state messages, and this is the point when working with useOptimistic(), the optimized state is exposed in the JSX instead of the normal state.
Inside the map() function, each message is displayed in an <li> tag. I assign a specific class to each message with messageClass() function to differentiate the sender and receiver users.
To show a feeling to the user that the message is sending we check for message.status flag which display a “sending…” label or a button to retry send the message again.
{
message.status === 'sending' ? <small>sending...</small> :
message.status === 'error' ? <a href="#" className="retry" onClick={(e) => RetrySend(e, message, index)}>Retry</a> : null
}
When the form submitted, the handleSubmit() function is triggered, let’s see the handleSubmit() code:
function handleSubmit(e) {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const message = formData.get("message");
if(!message) return;
startTransition(async () => {
addOptimisticMessage(message);
try {
await sendMessage(message);
setMessages([...messages, {
text: message,
time: new Date()
}]);
// Reset form
form.reset();
} catch (error) {
setMessages([...messages, {
text: message,
time: new Date(),
status: 'error'
}]);
}
});
}
In handleSubmit() i retrieve the entered message by creating a new formData instance from the form target.
The startTransition() function is invoked to start a new transition, by adding an async function, and inside we invoked our addOptimisticMessage() function which will trigger the reducer function in useOptimistic().
When the reducer function is executed, it will return a new merged state immediately which cause the message to appear instantly to the user in the UI.
The async request which is this case the sendMessage() function must be wrapped between a try catch block so that we can catch errors and update the state if any errors happened.
After that we update the base state with setMessages() to append to new messages and then reset the form to clear the input field.
In case there is an error, we also update the base state but the appended message have an error status:
setMessages([...messages, {
text: message,
time: new Date(),
status: 'error'
}]);
In case of failure to deliver the message the retry link is shown to retry sending the message:
function retrySend(e, message, index) {
e.preventDefault();
startTransition(async () => {
try {
await sendMessage(message);
setMessages(prevState => {
const next = [...prevState];
delete next[index].status;
return next;
});
} catch (error) {
setMessages(prevState => {
const next = [...prevState];
next[index].status = 'error';
return next;
});
}
});
}
We execute a similar logic in the retrySend() function by calling the startTransition() again and calling the async sendMessage() function, but this time instead of appending a new message, we update the message by index:
await sendMessage(message);
setMessages(prevState => {
const next = [...prevState];
delete next[index].status;
return next;
});
This is code for sendMessage() function:
async function sendMessage(message) {
await new Promise((res) => setTimeout(res, 1000));
return message;
}
The sendMessage() function return a promise that the message text is sent, for real world example this will make a fetch request to backend language, Node, PHP or whatever server.
To simulate an error from the sendMessage(), return a reject promise like so:
async function sendMessage(message) {
return new Promise((resolve, reject) => setTimeout(() => {
reject(new Error('Something went wrong'));
}, 1000));
}
The full source code in the repository.
Conclusion
The useOptimistic hook is a game changer for modern React apps. It lets developers:
-
Make UI feel instant
-
Handle server delays gracefully
-
Avoid flickers or disappearing elements


