React 19 useOptimistic: Build Instant UI Without Waiting for the Server
Source: Dev.to
Introduction
useOptimistic is one of the most underused hooks in React 19. Instead of relying on local state plus loading spinners, you can ship instant UI updates that feel snappy while the server request runs in the background.
Using useOptimistic
Old approach – users see lag
async function addTodo(text: string) {
setLoading(true);
const newTodo = await createTodo(text); // 200‑800 ms wait
setTodos(prev => [...prev, newTodo]);
setLoading(false);
}
With useOptimistic
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newText: string) => [
...currentTodos,
{ id: crypto.randomUUID(), text: newText, pending: true }
]
);
async function addTodo(text: string) {
addOptimisticTodo(text); // Instant UI update
const newTodo = await createTodo(text); // Background request
setTodos(prev => [...prev, newTodo]);
}
The item appears instantly. If the request fails, the optimistic update rolls back automatically.
Core API
const [optimisticState, dispatchOptimistic] = useOptimistic(
state, // real state (from server/parent)
updateFn // (currentState, action) => nextOptimisticState
);
React automatically reverts optimisticState back to state when the async action finishes—whether it succeeds or throws. No manual rollback is required.
Example: Todo List
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, text: string) => [
...state,
{ id: crypto.randomUUID(), text, completed: false, pending: true }
]
);
async function formAction(formData: FormData) {
addOptimisticTodo(formData.get("todo") as string);
await saveTodo(formData.get("todo") as string);
}
return (
<>
{optimisticTodos.map(todo => (
<div key={todo.id}>
{todo.text}
</div>
))}
<form onSubmit={e => {
e.preventDefault();
const data = new FormData(e.currentTarget);
formAction(data);
}}>
<input name="todo" />
<button type="submit">Add</button>
</form>
</>
);
}
Example: Like Toggle
const [optimistic, dispatch] = useOptimistic(
{ liked, count },
(current) => ({
liked: !current.liked,
count: current.liked ? current.count - 1 : current.count + 1,
})
);
async function handleLike() {
dispatch(null); // Instant toggle
const result = await toggleLike(postId);
setLike(result);
}
Handling Failures
useOptimistic rolls back only when the async action throws. Silent failures will leave the optimistic state stuck.
Bad pattern – no throw
async function save(data) {
const json = await fetch(...).then(r => r.json());
if (!json.success) return json.error; // returns, doesn't throw
}
Good pattern – throw on error
async function save(data) {
const res = await fetch(...);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
Example with error handling
async function submitComment(formData: FormData) {
const text = formData.get("comment") as string;
setError(null);
addOptimisticComment(text);
try {
const comment = await postComment(postId, text);
setComments(prev => [...prev, comment]);
} catch {
setError("Failed to post. Try again.");
// useOptimistic auto-reverts the optimistic entry
}
}
Common Pitfalls
- Large server changes – If the server response replaces the optimistic placeholder with significantly different data, the UI may flash.
- High‑conflict scenarios – Collaborative editing, inventory counts, or financial writes can cause frequent rollbacks.
- Irreversible actions – Deletions, charges, or sent emails should be confirmed before applying an optimistic update.
When to Use useOptimistic
- Adds, likes, toggles, reorders, soft deletes.
- Any CRUD operation where the perceived latency (the time until the UI shows a result) is the primary user concern.
useOptimisticdoesn’t make the server faster; it makes the app feel faster by showing results before the server responds.
Resources
- React 19 + Next.js Server Actions + optimistic UI – The AI SaaS Starter Kit ships with everything pre‑wired, saving you from a lengthy setup.