Explore Blogs
Optimistic UI with Concurrent React: Instant Feedback, No Hacks

Optimistic UI patterns make apps feel lightning-fast by updating the interface before server confirmation. With Concurrent React, these patterns are now easier, safer, and more robust. This blog dives into advanced optimistic strategies using useOptimistic and startTransition, showing how to streamline feedback, handle rollback gracefully, and keep your UI always one step ahead.
We've all experienced it — clicking a button and waiting while the server does its thing. The moment between user action and server response is a dead zone for engagement. Optimistic UI tackles this by showing the result of an action before the server confirms it. It's not about faking outcomes — it's about respecting user intent and smoothing over backend latency. When done right, optimistic UI can make an app feel instantaneous.
Take a basic example: You like a photo on social media. You don't want to wait for a response from the server just to see your like registered. Optimistic UI makes the like count go up right away — then reconciles later. That's what makes apps feel snappy.
What is Optimistic UI?
Optimistic UI is a frontend technique where the interface updates immediately, assuming the action will succeed. Instead of waiting for the server to reply, the UI reflects the user's intention right away.
Examples:
- Like button: Count updates instantly when clicked.
- Chat app: New message appears immediately in the thread.
- Todo list: Adding an item updates the UI before saving.
Later, when the server responds, the UI either confirms or reverts the change. This pattern minimizes perceived latency, making apps feel fluid and responsive.
Challenges with traditional optimistic UI
Manually implementing optimistic updates traditionally required:
- Tracking temporary shadow state separate from server truth.
- Writing duplicate state reconciliation logic.
- Handling rollback with
try/catch
and optimistic UI invalidation. - Managing race conditions when multiple actions overlap.
- Preventing flicker on fast/failing round-trips.
A common workaround included ad-hoc solutions like tempId
, status: 'saving'
, or error: true
flags baked into your data. It worked, but it didn't scale.
Consider a multi-user collaborative board. If two users move the same card optimistically, naive client-side logic can break ordering, override data, or show incorrect feedback.
How concurrent react helps
React's concurrent features give you precise control over what happens now vs soon.
startTransition(fn)
Wrap expensive or non-urgent updates in startTransition
, ensuring they run in the background without blocking immediate feedback.
startTransition(() => {
setData(fetchNewState());
});
In optimistic UI, this lets you show the change immediately, and run reconciliation logic lazily.
useOptimistic
This experimental API manages a speculative state layer on top of actual state. Unlike useState
, useOptimistic
applies a transformation function that defines how to blend optimistic updates with your real state.
const [optimisticState, addOptimistic] = useOptimistic(
realState,
(current, newItem) => [...current, { ...newItem, optimistic: true }]
);
Benefits:
- Minimal boilerplate.
- Integrated rollback through reconciliation.
- Built-in separation of intent (optimistic) vs reality (actual state).
- Smooth merging without managing temp IDs manually.
Example: Optimistic comment submission with recovery
Goal of the example:
Build a comment form that:
- Updates the UI immediately with the new comment.
- Sends the comment to the server in the background.
- Replaces the optimistic comment with the real one once confirmed.
- Rolls back the UI if submission fails.
Let's deepen the example with validation, rollback, and transition control.
'use client';
import { useState, useOptimistic, startTransition, useRef } from 'react';
export default function CommentForm() {
const [comments, setComments] = useState([]);
const [optimisticComments, addOptimistic] = useOptimistic(comments, (prev, comment) => [...prev, { ...comment, optimistic: true, timestamp: Date.now() }]);
const rollbackQueue = useRef([]);
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const comment = { text: formData.get('text') };
const optimisticId = Math.random().toString(36);
rollbackQueue.current.push(optimisticId);
addOptimistic({ ...comment, id: optimisticId });
startTransition(async () => {
try {
const serverResponse = await submitComment(comment);
const updated = await fetchComments();
setComments(updated);
rollbackQueue.current = rollbackQueue.current.filter((id) => id !== optimisticId);
} catch (e) {
console.warn('Reverting', optimisticId);
setComments((prev) => prev.filter((c) => c.id !== optimisticId));
showToast('Comment failed to send.');
}
});
}
return (
<form onSubmit={handleSubmit}>
<input
name="text"
placeholder="Add a comment"
/>
<button type="submit">Submit</button>
<ul>
{optimisticComments.map((c) => (
<li
key={c.id || c.timestamp}
style={{ opacity: c.optimistic ? 0.4 : 1 }}
>
{c.text} {c.optimistic && '⏳'}
</li>
))}
</ul>
</form>
);
}
This version handles:
- Conflict-safe optimistic updates.
- Explicit rollback via
rollbackQueue
. - Feedback for pending vs confirmed state.
Breakdown by section:
useState([])
and useOptimistic(...)
const [comments, setComments] = useState([]);
const [optimisticComments, addOptimistic] = useOptimistic(comments, (prev, comment) => [...prev, { ...comment, optimistic: true, timestamp: Date.now() }]);
comments
: the true state received from the server.optimisticComments
: includes bothcomments
and any new ones speculatively added.addOptimistic
: allows you to push a new item into theoptimisticComments
list before it actually exists on the server.optimistic: true
: used for visual styling and rollback logic.timestamp
: gives fallback identity when the comment has no realid
yet.
rollbackQueue
Ref
const rollbackQueue = useRef([]);
Used to track id
s of comments that were optimistically added and might need to be rolled back later.
It's a stable object across renders (via useRef
) and doesn't trigger re-renders.
Form Submission Handler
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const comment = { text: formData.get('text') };
const optimisticId = Math.random().toString(36);
rollbackQueue.current.push(optimisticId);
- Prevents the form from refreshing the page.
- Extracts comment text from form data.
- Generates a random optimistic ID to uniquely identify this local-only comment.
- Stores this ID in a rollback queue for future reference.
addOptimistic({ ...comment, id: optimisticId });
Immediately shows the comment in the UI via the useOptimistic
hook.
startTransition(async () => {
try {
const serverResponse = await submitComment(comment);
const updated = await fetchComments();
setComments(updated);
rollbackQueue.current = rollbackQueue.current.filter(id => id !== optimisticId);
} catch (e) {
setComments(prev => prev.filter(c => c.id !== optimisticId));
showToast(\"Comment failed to send.\");
}
});
Wraps the network logic in a transition so the UI stays responsive.
After the comment is submitted, we fetch the actual updated list from the server and replace the entire comment state (setComments(updated)
).
If the network request fails:
- We remove the optimistic comment by filtering it out using the stored
optimisticId
. - A toast message tells the user what went wrong.
The render section
<ul>
{optimisticComments.map(c => (
<li key={c.id || c.timestamp} style={{ opacity: c.optimistic ? 0.4 : 1 }}>
{c.text} {c.optimistic && '⏳'}
</li>
))}
</ul>
Each comment:
- Uses a fallback
timestamp
if no stableid
exists yet. - Is styled with
opacity: 0.4
if it's optimistic (pending confirmation). - Displays a clock emoji for visual feedback (
⏳
).
What this pattern solves
Problem | How this solves it |
---|---|
UI delay between submit & response | Shows the comment immediately with addOptimistic() |
State flicker when fetching | Transitions keep UI stable while server fetch runs |
Rollback logic complexity | Uses IDs + rollbackQueue to revert on failure |
Double-rendering vs stale data | Replaces all state from fetchComments() after success |
User feedback | Visual opacity + toast for failed submissions |
Ideas for further expansion
- Retry failed optimistic actions from
rollbackQueue
. - Store a
status: 'pending' | 'confirmed' | 'failed'
inside each comment. - Integrate WebSocket listeners to re-sync after server broadcasts update.
- Use
@tanstack/react-query
orSWR
for cache invalidation after mutation.
Visual feedback and rollback
For production use, treat UX as seriously as data correctness.
- Loading indicator: Use spinners or timers for pending states.
- Toast fallback: Notify users gracefully when updates fail.
- Error boundaries: Prevent entire trees from crashing due to network errors.
You can even use suspense boundaries around transitions:
<Suspense fallback={<LoadingComments />}>
<CommentList />
</Suspense>
And combine with useTransition
for controlled UI deferral.
Advanced pattern: Queueing optimistic updates
In collaborative or chat-like environments, updates must preserve intent and order.
Strategy: Command queues
Instead of managing local state manually, enqueue operations.
const [queue, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'enqueue':
return [...state, { ...action.payload, optimistic: true }];
case 'ack':
return state.map(item =>
item.id === action.payload.tempId ? action.payload : item
);
case 'fail':
return state.filter(item => item.tempId !== action.payload.tempId);
default:
return state;
}
}, []);
Send actions through a shared handler with timestamp + user ID to preserve intent order across clients.
Server-side strategies:
- Use CRDTs or OT for multi-user scenarios.
- Assign server-side canonical ordering.
- Allow client to show speculative changes until confirmed.
Caveats and edge cases
Optimism introduces risk. Use it strategically.
Unsafe for:
- Financial transactions
- Multi-user destructive actions (delete, override)
- Actions without idempotency guarantees
Defensive tips:
- Always validate on server.
- Use optimistic UI, not data when unsure.
- Fall back to transition-only loading when necessary.
Wrap-up: The real power of concurrent UI
Optimistic UI bridges UX intent and server reality. With Concurrent React:
- You no longer need to hand-roll rollback mechanisms.
- You can blend speculative and real data elegantly.
- Your UI reflects what the user wants now — even if the backend lags.
The transition toward speculative interfaces is growing. If you want your app to feel immediate, personal, and polished — optimism powered by concurrency is the future.
Start with a form. Expand to live feeds. Eventually, bring this mindset to everything from search to dashboards to multi-user collaboration.