Optimistic UI Updates
Show the result of a submission instantly — before the server responds — and roll back automatically if something goes wrong.
See live demo →Why it matters
Optimistic UI is the difference between a form that feels fast and one that feels slow. Instead of waiting 500ms for the server to respond before updating the list, you project the expected result immediately and confirm (or roll back) once the server replies.
hookform-action implements this via React's useOptimistic (React 19) with a fallback for React 18. You provide an optimisticData reducer that produces the projected state from the current data and the form values. The hook handles the transition, confirmation, and rollback automatically.
Full Example — Optimistic Todo List
'use server'
export interface Todo {
id: string
text: string
done: boolean
}
// In-memory store for demo purposes
let todos: Todo[] = [
{ id: '1', text: 'Read the docs', done: true },
{ id: '2', text: 'Build something', done: false },
]
export async function addTodoAction(raw: unknown) {
// Simulate network latency
await new Promise((r) => setTimeout(r, 1200))
const data = raw as { text: string }
if (!data.text?.trim()) {
return { errors: { text: ['Todo text is required'] }, todos }
}
// Type 'fail' to simulate a server error and trigger rollback
if (data.text.toLowerCase().includes('fail')) {
throw new Error('Server error — optimistic update will be rolled back.')
}
const newTodo: Todo = {
id: crypto.randomUUID(),
text: data.text.trim(),
done: false,
}
todos = [...todos, newTodo]
return { todos }
}'use client'
import { useActionForm } from 'hookform-action'
import { type Todo, addTodoAction } from './actions'
type AddTodoResult = { todos: Todo[]; errors?: { text?: string[] } }
const initialTodos: Todo[] = [
{ id: '1', text: 'Read the docs', done: true },
{ id: '2', text: 'Build something', done: false },
]
export function TodoForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isPending, actionResult },
optimistic,
} = useActionForm<{ text: string }, AddTodoResult, Todo[]>(addTodoAction, {
defaultValues: { text: '' },
// ── Optimistic UI configuration ─────────────────────────────
optimisticKey: 'todos',
// Initial "confirmed" data before any submissions
optimisticInitial: initialTodos,
// Reducer: given the current list and the submitted values,
// return the projected list to show immediately
optimisticData: (current, values) => [
...current,
{ id: `temp-${Date.now()}`, text: values.text, done: false },
],
// ────────────────────────────────────────────────────────────
onSuccess: () => reset(),
})
// Decide which data to render:
// • While the action is in flight → show the optimistic (projected) list
// • After the action resolves → show the confirmed list from the server
const confirmedTodos = actionResult?.todos ?? initialTodos
const todos = optimistic?.isPending
? (optimistic.data ?? confirmedTodos)
: confirmedTodos
return (
<div>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
className={
todo.id.startsWith('temp-')
? 'opacity-60 italic text-brand-300' // optimistic item style
: 'text-gray-200'
}
>
{todo.done ? '✅' : '⬜'} {todo.text}
{todo.id.startsWith('temp-') && ' (saving…)'}
</li>
))}
</ul>
<form onSubmit={handleSubmit()} className="mt-4 flex gap-2">
<input
{...register('text')}
placeholder="New todo…"
disabled={isPending}
/>
{errors.text && <p className="text-red-400">{errors.text.message}</p>}
<button type="submit" disabled={isPending}>Add</button>
</form>
</div>
)
}Key Concepts
optimisticData (reducer)A pure function (currentData, formValues) => newData that computes the projected state. It runs synchronously on submit — before the server responds. Keep it fast and side-effect-free.
optimistic.data vs actionResultoptimistic.data is the projected state (may contain temporary items with fake IDs). actionResult is the confirmed state returned by the server. Use the pattern shown above: prefer optimistic.data while optimistic.isPending is true, then switch to actionResult.
Automatic rollback on errorWhen the action throws, the hook automatically reverts optimistic.data to the last confirmed state. You can also trigger a manual rollback via optimistic.rollback() for business-logic-driven reversals.
React 18 / React 19 compatibilityOn React 19 the hook uses the native useOptimistic API. On React 18 it uses a local state fallback. The behaviour is identical — you don't need to change any code when upgrading React.
⚠️ Pitfalls
- ⚠
Rendering
optimistic.dataafter the action resolvesAfter the action succeeds,
optimistic.datastill contains the projected state with temporary IDs. Always switch toactionResultonceoptimistic.isPendingisfalse. - ⚠
Inline object literal for
optimisticInitialDefine
optimisticInitialoutside the component or useuseMemo. An inline array literal creates a new reference on every render and resets the optimistic state unexpectedly. - ⚠
Forgetting
reset()inonSuccessAfter a successful add, the text input stays filled. Call
reset()inonSuccessto clear the input so the user can type the next item immediately.