Have you ever clicked a "Like" button on a social media app and stared at it for a solid second before it actually turned red? It feels clunky. It feels slow. In a world where we expect our apps to move at the speed of thought, that tiny delay between a user's action and the interface's reaction can break the illusion of fluidity.
This is where React 19 useOptimistic changes the game.
For years, developers have wrestled with "Optimistic UI"—the pattern of showing a successful state before the server actually confirms it. We used to write complex logic to toggle loading spinners, manage temporary local state, and frantically revert changes if the server request failed. It was boilerplate-heavy and prone to bugs.
With React 19, the React team has baked this pattern directly into the library. In this guide, we are going to dive deep into implementing instant UI updates using useOptimistic. We’ll move beyond the theory and build real components that feel snappy, responsive, and modern.
Introduction to Optimistic UI
Before we look at code, let's talk about the feeling of software.
Optimistic UI is a design pattern that prioritizes user perceived performance. Instead of waiting for the server to say, "Okay, I saved that data," the interface assumes the best-case scenario (optimism!) and updates immediately.
The Traditional Flow (Pessimistic):
- User clicks "Send".
- UI shows a loading spinner.
- App waits for the server response (200ms - 2s).
- Server confirms success.
- UI updates to show the sent message.
The Optimistic Flow:
- User clicks "Send".
- UI immediately shows the sent message.
- App sends the data in the background.
- Server confirms (or rejects) silently.
If the server succeeds, the user never noticed the delay. If it fails, the UI seamlessly rolls back to the previous state (and maybe shows an error toast). This slight sleight of hand makes web applications feel like native desktop apps.
What is useOptimistic?
The useOptimistic hook is a new primitive introduced in React 19 designed to handle this exact scenario without the headache of manual state management.
Technically speaking, it allows you to optimistically update the UI while an asynchronous action (like a Server Action) is pending. It takes a piece of state—usually data coming from the server—and lets you "overlay" a temporary version of that state that only exists while a mutation is in progress.
Think of it like a layer in Photoshop. You have your base image (the real server data). When the user clicks a button, useOptimistic throws a temporary layer on top with the changes. Once the server responds with the new "real" image, the temporary layer is discarded, and the new base image is revealed.
Here is the basic signature:
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
state: The initial value, usually the current data from your server or parent component.updateFn: A pure function (reducer) that takes the current state and the new optimistic value, merging them to produce the temporary state.optimisticState: The value you use to render your UI. It will return thestatewhen idle, or the result ofupdateFnwhen an action is pending.addOptimistic: The function you call to trigger the optimistic update.
The Problem useOptimistic Solves
If you have been building React apps for a while, you might ask, "Can't I just use useState for this?"
You absolutely can, but it gets messy fast. Let's look at the "Old Way" of doing an optimistic update for a simple todo list item.
The Old Way (Pre-React 19):
- Create a local state
todos. - Create a separate
isSubmittingstate. - When adding a todo:
- Create a fake todo object.
- Manually append it to the
todosstate. - Send the API request.
try/catchthe request.- If it fails, filter the
todosstate to remove the fake item. - If it succeeds, replace the fake item with the real ID from the database.
That is a lot of imperative logic for one feature. You have to manage synchronization between the "truth" (server data) and your "lie" (local state). If props change while a request is pending, you might overwrite data.
The useOptimistic Way: You don't manage the synchronization. You just tell React: "While this action is running, make the data look like this." React handles the cleanup automatically when the action finishes. The rollback isn't something you code manually; it's just the natural end of the hook's lifecycle.
Getting Started: Setting Up Your React 19 Project
To follow along, you need a React 19 environment. As of late 2024/early 2025, React 19 is stable, and most frameworks like Next.js (App Router) support it out of the box.
If you are starting fresh, creating a Next.js app is usually the easiest way to get access to Server Actions, which pair beautifully with useOptimistic.
npx create-next-app@latest my-optimistic-app
cd my-optimistic-app
npm install
Make sure your package.json reflects React 19. If you are using plain Vite, ensure you have the latest versions:
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
Basic useOptimistic Implementation: A 'Like' Button
Let's start with the "Hello World" of optimistic updates: the Like button.
Imagine we have a component that receives a likeCount prop from the server. We want the number to jump up immediately when clicked.
The Code
// LikeButton.jsx
"use client";
import { useOptimistic, startTransition } from "react";
import { likePost } from "./actions"; // Imagine this is a Server Action
export default function LikeButton({ likeCount, postId }) {
// 1. Setup the hook
// optimisticLikes: the value to display
// addOptimistic: function to trigger the update
const [optimisticLikes, addOptimistic] = useOptimistic(
likeCount,
(currentLikes, newIncrement) => currentLikes + newIncrement
);
const handleLike = async () => {
// 2. Wrap in startTransition (or use within a form action)
startTransition(async () => {
// 3. Update the UI instantly
addOptimistic(1);
// 4. Perform the actual async operation
await likePost(postId);
});
};
return (
<button
onClick={handleLike}
className="flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
>
<span>👍</span>
<span className="font-bold">{optimisticLikes}</span>
</button>
);
}
Breakdown
- State Initialization: We pass
likeCount(the real server data) as the initial value. - The Reducer: The second argument
(currentLikes, newIncrement) => currentLikes + newIncrementtells React how to calculate the optimistic state. - Triggering: Inside
handleLike, we calladdOptimistic(1). The UI immediately reflectslikeCount + 1. - The Server Call: We await
likePost(postId). While this is pending,optimisticLikesstays incremented. - Reconciliation: Once
likePostfinishes, React re-renders. If the parent component re-fetches data and passes a newlikeCount,useOptimisticdiscards the temporary state and uses the new prop.
Notice how clean this is? No try/catch for reverting state. No isLoading flags.
Handling Form Submissions with useOptimistic
The Like button is simple because it's a single value. Forms are where useOptimistic truly shines, especially for lists of data, like a comment section.
Let's build a comment thread where your comment appears instantly while it saves to the database.
The Scenario
We have a list of messages. When the user types a message and hits Enter, we want it to append to the list immediately with a "Sending..." opacity style.
The Implementation
// MessageList.jsx
"use client";
import { useOptimistic, useRef } from "react";
import { sendMessage } from "./actions"; // Server Action
export default function MessageList({ initialMessages }) {
const formRef = useRef(null);
// 1. Define the optimistic state
// We expect 'state' to be an array of message objects
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
initialMessages,
(currentMessages, newMessage) => {
// Return a new array with the optimistic message added
return [...currentMessages, newMessage];
}
);
const formAction = async (formData) => {
const messageText = formData.get("message");
// 2. Create the optimistic message object
// We generate a temp ID so React keys don't complain
const tempMessage = {
id: crypto.randomUUID(),
text: messageText,
sending: true, // Flag to style differently
};
// 3. Update UI immediately
addOptimisticMessage(tempMessage);
// 4. Reset form immediately for better UX
formRef.current?.reset();
// 5. Send to server
// Note: The Server Action should revalidate the data path
await sendMessage(messageText);
};
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-xl shadow-sm">
<ul className="space-y-4 mb-6">
{optimisticMessages.map((msg) => (
<li
key={msg.id}
className={`p-3 rounded-lg ${
msg.sending ? "bg-gray-100 opacity-70" : "bg-blue-50"
}`}
>
<p className="text-gray-800">{msg.text}</p>
{msg.sending && (
<span className="text-xs text-gray-400">Sending...</span>
)}
</li>
))}
</ul>
<form action={formAction} ref={formRef} className="flex gap-2">
<input
name="message"
type="text"
placeholder="Type a message..."
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700"
>
Send
</button>
</form>
</div>
);
}
Why this works seamlessly
When using <form action={formAction}>, React automatically handles the transition context. You don't need startTransition here; the form action wrapper does it for you.
When addOptimisticMessage is called, the list updates. The sending: true property allows us to apply specific styles (like opacity) to indicate the data hasn't persisted yet. This is a crucial UX pattern—instant feedback, but with honesty that it's still processing.
Error Handling and Rollbacks
What happens when things go wrong? The server is down, or the user is offline.
One of the best features of useOptimistic is that it does not persist state. It only exists while the async action is pending.
If sendMessage throws an error:
- The async function in
formActionrejects. - The "pending" state of the transition ends.
- React re-renders.
- Since the action is no longer pending,
useOptimisticstops returning the optimistic value and reverts toinitialMessages. - The optimistic message simply vanishes from the UI.
However, silently removing a user's message is bad UX. You still need to handle the error notification.
Adding Error Feedback
We can combine useActionState (another React 19 hook, formerly useFormState) with useOptimistic to manage errors.
// ... imports
import { useActionState } from "react";
export default function RobustMessageList({ initialMessages }) {
// Setup action state for errors
const [state, action, isPending] = useActionState(sendMessage, null);
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
initialMessages,
// ... same reducer as before
);
const handleSubmit = (formData) => {
const text = formData.get("message");
addOptimisticMessage({ id: Math.random(), text, sending: true });
// Call the actual action wrapped by useActionState
action(formData);
};
return (
<div>
{/* ... render list ... */}
<form action={handleSubmit}>
{/* ... inputs ... */}
</form>
{/* Show error if the server action returned one */}
{state?.error && (
<div className="text-red-500 text-sm mt-2">
Failed to send: {state.error}
</div>
)}
</div>
);
}
In this setup, if the server action fails, the list reverts (removing the message), but the state.error variable populates, allowing you to show a red error message explaining what happened.
Advanced Patterns and Best Practices
1. Queueing Multiple Updates
Because useOptimistic uses a reducer, it can handle multiple updates gracefully. If a user clicks "Like" five times rapidly, the reducer runs five times, stacking the increments.
(currentLikes, newIncrement) => currentLikes + newIncrement
If currentLikes is 10, and you call addOptimistic(1) three times, the optimistic state becomes 13. When the server actions resolve, they will eventually settle on the true server count.
2. Complex Data Structures
Don't limit yourself to simple arrays or counters. You can use it for sorting or filtering too.
Imagine a table with a "Delete" button.
const [optimisticRows, removeOptimisticRow] = useOptimistic(
rows,
(currentRows, idToDelete) => currentRows.filter(row => row.id !== idToDelete)
);
The row vanishes instantly. If the deletion fails server-side, it pops back into existence.
3. Combining with useTransition
If you aren't using a <form>, remember that useOptimistic only works inside a transition or Action.
If you trigger an update from a useEffect or a standard event handler without a transition, the optimistic state won't apply correctly because React won't know when to revert it. Always wrap imperative calls:
startTransition(async () => {
addOptimistic(newData);
await updateData(newData);
});
useOptimistic vs. Other Approaches
You might be wondering how this stacks up against libraries like TanStack Query (React Query) or SWR.
React Query
React Query has been the gold standard for optimistic updates for years. It works by manipulating the global query cache.
- Pros: Global cache updates. If you update a todo in one component, it updates everywhere that uses that query key.
- Cons: Requires more setup (
onMutate,onError,onSettled). It's imperative logic.
useOptimistic
- Pros: Native to React. incredibly simple API. Zero boilerplate for local UI feedback. Works flawlessly with Server Components and Actions.
- Cons: It is local state. If you optimistically update a "Like" count in a card component, it won't automatically update the "Like" count in a header component unless you lift that state up or pass the optimistic value around.
Verdict:
- Use React Query if you need to update data that is shared across many disconnected parts of your application simultaneously.
- Use useOptimistic for direct feedback on the specific component the user is interacting with (forms, buttons, lists). It is lighter and cleaner for 90% of interaction cases.
Conclusion
React 19's useOptimistic hook effectively democratizes high-end user experience. Features that used to require senior-level knowledge of state management patterns—instant feedback, rollback logic, and race condition handling—are now available via a single hook.
By decoupling the "perceived" state from the "actual" state, we make our applications feel more respectful of the user's time. We stop forcing them to wait for network latency and start treating their interactions as the source of truth, with the server catching up in the background.
To recap:
- Wrap your initial state (props) with
useOptimistic. - Define a reducer to merge the temporary state.
- Trigger
addOptimisticinside a Server Action or Transition. - Enjoy the immediate snap of your UI.
Go ahead and refactor that laggy form or that slow toggle button. Your users might not know exactly what changed, but they will definitely feel the difference.
Happy coding!