Optimistic UI Updates
useOptimistic and useOptimisticAction let you show the result of a mutation in the UI before the server confirms it, then automatically reconcile when the server responds. They compose with the existing useAction hook with no changes to your loaders or actions.
Why two hooks?
useOptimisticis a primitive: it maintains a queue of pending changes layered over a base value. You hold the queue handles and decide when to settle (success) or revert (error) each entry. Use it directly when you need full control or when one piece of optimistic state is fed by multiple actions.useOptimisticActionis a wrapper arounduseAction+useOptimisticfor the common single-action case. It owns the queue lifecycle for you.
Start with useOptimisticAction. Drop down to the primitive if you outgrow it.
useOptimisticAction
import { useOptimisticAction, Form } from 'hono-preact';
import { serverActions } from './movies.server.js';
const Movies = ({ loaderData }) => {
const addMovie = useOptimisticAction(serverActions.create, {
base: loaderData.movies,
apply: (current, payload) => [...current, payload],
invalidate: 'auto',
onSuccess: (data) => console.log('created', data),
onError: (err) => console.error(err),
});
return (
<>
<ul>
{addMovie.value.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
<Form action={addMovie}>
<input name="title" placeholder="Title" />
<button type="submit">Add</button>
</Form>
</>
);
};
value is the projection: base with all in-flight payloads applied via apply. While the mutation is in flight, addMovie.value includes the optimistic entry; after the server responds and the loader refetches (invalidate: 'auto'), addMovie.value reflects real server data with no visual gap.
The returned object is stub-compatible: pass it to <Form action={addMovie}> for declarative form submission, or call addMovie.mutate(payload) for programmatic invocation. Both paths participate in the optimistic queue. Access addMovie.pending, addMovie.error, and addMovie.data to read the mutation status and result.
Options
| Option | Type | Description |
|---|---|---|
base | TBase | The base value (typically loader data) the projection layers over |
apply | (current, payload) => TBase | Reducer that produces the next projection |
invalidate | 'auto' | LoaderRef<unknown>[] | Refetch trigger after mutation succeeds. false is intentionally not allowed (see below). |
onSuccess | (data) => void | Called after a successful mutation. Snapshot is internal; not exposed here. |
onError | (err) => void | Called after a failed mutation. The optimistic entry is reverted automatically before this fires. |
Other useAction options (onChunk) pass through.
Why no invalidate: false?
The optimistic entry settles into 'ready' state on success and waits for the base to update before evicting. Without an invalidation that refetches, the base never changes, the entry lingers, and the UI gets stuck. Use useOptimistic directly if you have a use case where base updates by another path.
useOptimistic (primitive)
import { useOptimistic, useAction } from 'hono-preact';
const Movies = ({ loaderData }) => {
const [movies, addOptimistic] = useOptimistic(
loaderData.movies,
(current, payload) => [...current, payload]
);
const { mutate } = useAction(serverActions.create, {
invalidate: 'auto',
onMutate: (payload) => addOptimistic(payload),
onSuccess: (_data, handle) => handle.settle(),
onError: (_err, handle) => handle.revert(),
});
return (
<ul>
{movies.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
);
};
addOptimistic(payload) appends a queue entry and returns an OptimisticHandle:
type OptimisticHandle = {
settle: () => void; // success: linger until base ref changes
revert: () => void; // error: remove immediately
};
The handle becomes the snapshot in useAction's onMutate/onSuccess/onError chain.
Concurrent mutations
Both APIs handle concurrent mutations correctly. If a user fires two mutations and the first completes before the second, the second's optimistic entry survives the first's settle-and-refetch:
queue=[A:active, B:active]
→ A succeeds, A.settle()
queue=[A:ready, B:active]
→ loader refetches, base updates (A confirmed)
→ A:ready evicted (base ref changed), B:active stays
queue=[B:active]
→ UI shows server-confirmed A + optimistic B
No special configuration needed.
Composing with <Form>
useOptimisticAction returns a stub-compatible value that you can pass directly to <Form action={...}>. The result carries the action's type brand, so the form knows how to invoke it and TypeScript enforces the payload shape.
const NotesForm = ({ defaultNotes }) => {
const notesAction = useOptimisticAction(serverActions.setNotes, {
base: defaultNotes,
apply: (_current, payload) => payload.notes,
invalidate: 'auto',
});
return (
<>
<p>Current: {notesAction.value}</p>
<Form action={notesAction}>
<textarea name="notes" defaultValue={notesAction.value} />
<button>Save</button>
</Form>
</>
);
};
The returned object exposes notesAction.mutate(payload) and notesAction.pending directly, so you can call it from an onClick handler or await it in an async function without holding a separate useAction ref.
<OptimisticOverlay>
<OptimisticOverlay> projects a list of pending actions onto the loader data that descendant components see via loader.useData(). Use it when a child component reads loader data and you want it to render against an optimistic projection without rewriting the child to take a prop.
import { OptimisticOverlay } from 'hono-preact/internal';
import { serverLoaders } from './movies.server.js';
const moviesLoader = serverLoaders.default;
const MovieList = () => {
const movies = moviesLoader.useData();
return (
<ul>
{movies.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
);
};
const MoviesPage = ({ pendingAdds }) => (
<OptimisticOverlay
loader={moviesLoader}
reducer={(base, action) => [...base, action]}
pending={pendingAdds}
>
<MovieList />
</OptimisticOverlay>
);
OptimisticOverlay lives in the hono-preact/internal subpath, which has no semver guarantee. Use it when you need projection through loader.useData(); prefer useOptimistic or useOptimisticAction for local optimistic state.
<OptimisticOverlay> must be inside a route or <Page> configured with the same loader it references; otherwise it throws.
| Prop | Type | Description |
|---|---|---|
loader | LoaderRef<T> | The loader whose data is being projected. Must match the surrounding route's loader. |
reducer | (base: T, action: A) => T | Folds each pending action into the base value. |
pending | A[] | Pending actions to project. Defaults to []. |
Prefer useOptimistic or useOptimisticAction when the optimistic state is local to the component that owns the mutation. Reach for <OptimisticOverlay> when the optimistic projection needs to flow through loader.useData() for descendants you don't want to thread props through.
View Transitions
useOptimistic and useOptimisticAction accept { transition: true } to
wrap settle and revert state changes in
document.startViewTransition.
See View Transitions for the full toolkit of named elements, lifecycle hooks, and direction-driven types.
The initial optimistic update is never wrapped so it paints in the same
frame. When startViewTransition is not available (older browsers or SSR),
the option is a no-op.
const [count, addOptimistic] = useOptimistic(serverCount, reducer, {
transition: true,
});
Style transitions with ::view-transition-old(*) and ::view-transition-new(*)
CSS pseudo-elements, or attach view-transition-name to specific elements
for element-level animations.