Problem
Most interfaces wait for the server before showing changes, so users stare at spinners and assume the app is lagging even on high‑confidence actions like likes, toggles, and comments. Rendering every update as urgent also blocks interactions on large trees or slower devices, creating visible jank.
Solution
Use React’s useOptimistic to render a predicted state immediately (for example, show a “Sending…” message) while the async action runs in the background. Wrap the final commit in startTransition so non‑urgent reconciliation stays responsive and doesn’t block higher‑priority interactions.
Minimal code
import { useState, useOptimistic, startTransition, useRef } from 'react';
// Simulated server action
async function sendMessageToServer(text: string) {
await new Promise((r) => setTimeout(r, 800));
// throw new Error('Network down'); // uncomment to test rollback
return { id: crypto.randomUUID(), text };
}
export default function Chat() {
const formRef = useRef<HTMLFormElement>(null);
const [messages, setMessages] = useState<{ id: string; text: string }[]>([]);
// Merge optimistic message at the top while pending
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(current, optimistic: { id: string; text: string }) => [
{ ...optimistic, pending: true } as any,
...current,
]
);
async function onSubmit(formData: FormData) {
const text = String(formData.get('text') || '').trim();
if (!text) return;
const tempId = `temp-${Date.now()}`;
// 1) Show instantly
addOptimisticMessage({ id: tempId, text });
formRef.current?.reset();
try {
// 2) Reconcile in a transition
startTransition(async () => {
const saved = await sendMessageToServer(text);
setMessages((prev) => [
{ id: saved.id, text: saved.text },
...prev.filter(m => m.id !== tempId),
]);
});
} catch {
// 3) Rollback on error
startTransition(() => {
setMessages((prev) => prev.filter(m => m.id !== tempId));
});
// Optionally trigger a toast or error state here
}
}
return (
<div>
<form ref={formRef} action={(fd) => onSubmit(fd)}>
<input name="text" placeholder="Write a message..." />
<button type="submit">Send</button>
</form>
<ul>
{optimisticMessages.map((m: any) => (
<li key={m.id}>
{m.text} {m.pending ? <em style={{ color: '#888' }}> (Sending…)</em> : null}
</li>
))}
</ul>
</div>
);
}
When to use
- High‑success, reversible actions: messages, likes, toggles, reorders, comments.
- Avoid for destructive/high‑stakes operations unless paired with confirmation and clear undo.
Gotchas
- startTransition is for prioritization; useTransition provides isPending if a pending indicator is required.
- After an awaited call, wrap subsequent setState in startTransition to keep it non‑urgent.
- Keep controlled inputs outside transitions, and ensure server actions are idempotent or de‑duplicated for safe retries.
Takeaway
Optimistic rendering reduces perceived latency, while transition‑based reconciliation prevents non‑urgent work from blocking interactions—resulting in UI that feels instant without sacrificing correctness.
Top comments (0)