Skip to main content

useOptimistic Hook Tutorial: Building Optimistic UIs

The useOptimistic hook is React 19's answer to a decade-old problem: how to give users instant feedback while waiting for a server response. Before React 19, implementing optimistic updates meant manually managing local state, reverting it on errors, and keeping two sources of truth in sync. The hook eliminates this boilerplate entirely, letting you declare the intended change and let React handle the rollback.

The useOptimistic hook takes your current state and an updater function, then immediately applies the update to a temporary copy. When the async operation succeeds, it syncs with the server state. If it fails, React reverts automatically. This pattern is essential for any real-time feature: messaging, comments, likes, shopping carts, and form submissions.

How useOptimistic Works Under the Hood

useOptimistic maintains two values: the real state from the server, and an optimistic version that may be ahead of it. When you call the action returned by the hook, it:

  1. Immediately applies your updater function to create the optimistic state
  2. Renders the UI with the new state
  3. Awaits the async operation (form submission, API call, etc.)
  4. On success: syncs the server response; no revert needed
  5. On error: reverts to the previous state silently

This is faster than waiting for a server round-trip before showing feedback. A messaging app can display your message instantly while it uploads; if the upload fails, the message disappears and shows an error.

import { useOptimistic, useRef } from 'react';

export function MessageList({ initialMessages }) {
const [messages, setMessages] = useOptimistic(initialMessages);
const formRef = useRef();

async function handleSendMessage(formData) {
const text = formData.get('message');

// Optimistic update: show the message immediately
addOptimisticMessage({
id: Date.now(),
text,
sender: 'You',
status: 'pending',
});

try {
// Server operation
const response = await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ text }),
});

if (!response.ok) throw new Error('Failed to send');

const newMessage = await response.json();
// Optimistic update succeeded; server confirms it
setMessages([...messages, newMessage]);
formRef.current.reset();
} catch (error) {
// useOptimistic automatically reverts the optimistic message
console.error('Failed to send message:', error);
}
}

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [...state, newMessage]
);

return (
<div className="message-list">
{optimisticMessages.map(msg => (
<div key={msg.id} className={msg.status === 'pending' ? 'pending' : ''}>
{msg.text}
</div>
))}
<form action={handleSendMessage} ref={formRef}>
<input name="message" type="text" placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
</div>
);
}

In this example, the message appears immediately (optimistic), and if the server call fails, it disappears. Users never see a delay—they see instant feedback, which is the foundation of snappy UIs.

Building a Shopping Cart with Optimistic Updates

E-commerce sites rely on optimistic updates. Adding an item to the cart should feel instant, even though the server needs to validate stock, update the database, and return a confirmation:

import { useOptimistic } from 'react';

export function ProductCard({ product, cartItems, onAddToCart }) {
const [optimisticCart, addOptimistic] = useOptimistic(
cartItems,
(state, { productId, quantity }) => {
const existing = state.find(item => item.id === productId);
if (existing) {
return state.map(item =>
item.id === productId
? { ...item, quantity: item.quantity + quantity }
: item
);
}
return [...state, { id: productId, quantity, name: product.name }];
}
);

const handleAddToCart = async () => {
// Show the item in the cart immediately
addOptimistic({ productId: product.id, quantity: 1 });

try {
const result = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, quantity: 1 }),
});

if (!result.ok) throw new Error('Failed to add to cart');

// Optionally refetch the cart to sync the backend state
// or rely on the optimistic update if it's already accurate
} catch (error) {
// Cart reverts to the previous state automatically
alert('Failed to add item. Please try again.');
}
};

return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<p>Cart items: {optimisticCart.length}</p>
<button onClick={handleAddToCart}>Add to Cart</button>
</div>
);
}

The optimistic updater receives the current state and the new data, then returns the updated state. React handles the async operation and revert logic—your job is just to describe what the update looks like.

Handling Errors and Showing Feedback

Optimistic updates work best when paired with proper error messaging. If an update fails, the UI reverts silently, but users need to know why they can't proceed:

import { useOptimistic, useState } from 'react';
import { useFormStatus } from 'react-dom';

export function LikeButton({ postId, liked, likeCount }) {
const [error, setError] = useState(null);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
{ liked, likeCount },
(state) => ({
liked: !state.liked,
likeCount: state.liked ? state.likeCount - 1 : state.likeCount + 1,
})
);

const handleLike = async () => {
setError(null);
addOptimisticLike();

try {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ liked: !optimisticLikes.liked }),
});

if (!response.ok) {
setError('Failed to update. Please try again.');
}
} catch {
setError('Network error. Please check your connection.');
}
};

return (
<div>
<button
onClick={handleLike}
className={optimisticLikes.liked ? 'liked' : ''}
>
{optimisticLikes.liked ? '❤️' : '🤍'} {optimisticLikes.likeCount}
</button>
{error && <span className="error">{error}</span>}
</div>
);
}

Note the status field in the optimistic state—it lets you visually distinguish between confirmed and pending updates. Users see a faded or animated style for optimistic items, so they understand the update is in flight.

Comparison: useOptimistic vs. Manual State Management

AspectManual useStateuseOptimistic
Lines of code per update15–25 (state setter, error handler, rollback logic)5–8 (just the updater function)
Automatic revert on errorNo—you must manually revertYes—built-in
Race condition riskHigh—multiple optimistic updates can conflictHandled by React's render batching
State duplicationYes—local and server state often divergeNo—one source of truth
Code reusabilityLow—each component reimplements the patternHigh—the hook encapsulates it

A real-world refactor from manual state to useOptimistic reduces a 40-line component to 20 lines while eliminating edge cases.

Key Takeaways

  • useOptimistic eliminates manual rollback logic by applying an updater function immediately and reverting on error.
  • Use it for any operation where instant feedback matters: messaging, likes, cart updates, and form submissions.
  • The hook pairs naturally with Server Actions and async form handlers.
  • Always pair optimistic updates with error messaging so users understand when something failed.
  • Optimistic updates improve perceived performance by 200–500ms per interaction, a measurable UX win.

Frequently Asked Questions

What's the difference between useOptimistic and setState?

setState updates state synchronously. useOptimistic creates a temporary copy that reverts if an async operation fails. Use useOptimistic when your update depends on a server response; use setState for local UI state (like toggling a menu).

Can I use useOptimistic without Server Actions?

Yes. useOptimistic works with any async operation: fetch calls, promises, async functions. Server Actions are optional. It was designed to work with forms and fetch equally well.

How do I know if an optimistic update succeeded?

You don't automatically—React doesn't confirm it. If your update is idempotent (the same operation produces the same result every time), you can skip confirmation. If not, refetch the state from the server after the operation completes to verify.

What if two optimistic updates conflict?

React batches renders and processes optimistic updates in order. If two updates happen in quick succession, the second updater receives the optimistic state from the first. If they conflict (both trying to increment a counter), the second one sees the already-incremented value. Design updaters to be compositional.

Is useOptimistic compatible with Server Actions?

Yes, perfectly. Server Actions automatically trigger form submission and return a response. useOptimistic reads the response and reverts if it fails. They're designed to work together seamlessly.

Further Reading