Optimistic UI: Make the App Feel Instant
Update the UI first, then confirm with the server using useOptimistic.
Optimistic UI updates show the expected result immediately, before the server confirms it. If the request succeeds, the UI was already right. If it fails, you deal with it afterward.
Waiting for the network before reflecting a user’s action makes an app feel broken, and the worse the connection, the more broken it feels. The user typed the todo. The todo can go on the list now.
Wait, Then Update
The cautious version. Nothing happens for a full second after submit:
import { useState } from "react";
async function saveTodo(todo) {
await new Promise(resolve => setTimeout(resolve, 1000));
return todo;
}
function App() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState("");
async function handleSubmit(e) {
e.preventDefault();
if (!newTodo.trim()) return;
try {
await saveTodo(newTodo); // UI does nothing until resolve
setTodos(prev => [...prev, newTodo]);
setNewTodo("");
} catch {
console.error("Failed to save");
}
}
return (
<div>
<h1>Optimistic UI Updates</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add Todo</button>
</form>
<ul>
{todos.map((todo, index) => <li key={index}>{todo}</li>)}
</ul>
</div>
);
}
export default App;
Update, Then Confirm
useOptimistic holds a temporary version of the list while the real request is in flight. The optimistic add shows up instantly, the transition keeps the input responsive, and the real state reconciles when the server answers:
import { useState, useOptimistic, useTransition } from "react";
async function saveTodo(todo) {
await new Promise(resolve => setTimeout(resolve, 1000));
return todo;
}
function App() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState("");
const [isPending, startTransition] = useTransition();
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, newTodo]
);
const handleSubmit = async (e) => {
e.preventDefault();
if (!newTodo.trim()) return;
startTransition(async () => {
addOptimisticTodo(newTodo); // show immediately
try {
await saveTodo(newTodo);
setTodos(prev => [...prev, newTodo]); // confirm
setNewTodo("");
} catch {
// optionally revert or show error
}
});
};
return (
<div>
<h1>Optimistic UI Updates</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a todo..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Adding..." : "Add Todo"}
</button>
</form>
<ul>
{optimisticTodos.map((todo, index) => <li key={index}>{todo}</li>)}
</ul>
</div>
);
}
export default App;
Render from optimisticTodos, not todos, or the user sees nothing until the request resolves and the pattern buys you nothing. On failure you can revert or surface a dismissible error with a retry, whichever hurts less for the action at hand. Keep optimistic updates idempotent so a double-submit doesn’t produce twins.