hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Signals
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
Session Channels
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Listbox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition

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?#

  • useOptimistic is 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.
  • useOptimisticAction is a wrapper around useAction + useOptimistic for 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: { refetchActive: true },
    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: { refetchActive: true }), 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, and addMovie.valueSignal when you want the update to land in a child instead of here (see below).

Narrowing re-renders#

Reading addMovie.value during render subscribes that component, so it re-renders whenever the projection changes. That is usually what you want: the component showing the list is the component that changes.

When the component that owns the action also renders something expensive, hand the list off to a child and give it addMovie.valueSignal instead:

const MovieList = ({ movies }) => (
  <ul>
    {movies.value.map((m) => (
      <li key={m.id}>{m.title}</li>
    ))}
  </ul>
);

const Movies = ({ loaderData }) => {
  const addMovie = useOptimisticAction(serverActions.create, {
    base: loaderData.movies,
    apply: (current, payload) => [...current, payload],
    invalidate: { refetchActive: true },
  });

  return (
    <>
      <MovieList movies={addMovie.valueSignal} />
      <ExpensiveChart data={loaderData.stats} />
      <Form action={addMovie}>
        <input name="title" placeholder="Title" />
        <button type="submit">Add</button>
      </Form>
    </>
  );
};

Now a dispatch re-renders MovieList and nothing else. Movies never reads .value, so it never subscribes, and ExpensiveChart stays put.

valueSignal is a ReadonlySignal<TBase> holding the same projection as value (see Signals). Reach for it when the surrounding render is worth skipping. value is simpler, and fine everywhere else.

Options#

OptionTypeDescription
baseTBaseThe base value (typically loader data) the projection layers over.
apply(current, payload) => TBaseReducer that produces the next projection
invalidateInvalidateInputRefetch trigger after mutation succeeds. Omitting it leaves the optimistic entry stuck (see below).
onSuccess(data) => voidCalled after a successful mutation. Snapshot is internal; not exposed here.
onError(err) => voidCalled after a failed mutation. The optimistic entry is reverted automatically before this fires.

Other useAction options (onChunk) pass through.

base is compared by contents, not by reference, so you can build it inline. An ?? [] for a missing loader field, an inline .filter(...), or a spread all hand the hook a fresh value every render, and none of them re-derive the projection unless the entries actually changed. The comparison is one level deep: a change nested inside an entry counts as a change, so you get an extra render rather than a stale one.

Why does invalidate need to actually refetch?#

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: { refetchActive: true },
    onMutate: (payload) => addOptimistic(payload),
    onSuccess: (_data, handle) => handle.settle(),
    onError: (_err, handle) => handle.revert(),
  });

  return (
    <ul>
      {movies.value.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  );
};

movies is a ReadonlySignal<TBase>; read .value to get the current projection, or pass the signal to a leaf so only that leaf re-renders (see Signals). addOptimistic(payload) appends a queue entry and returns an OptimisticHandle:

type OptimisticHandle = {
  settle: () => void; // success: linger until `base` 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 contents 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: { refetchActive: true },
  });

  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 { data } = moviesLoader.useData().value;
  if (!data) return <p>Loading...</p>;
  return (
    <ul>
      {data.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.

PropTypeDescription
loaderLoaderRef<T>The loader whose data is being projected. Must match the surrounding route's loader.
reducer(base: T, action: A) => TFolds each pending action into the base value.
pendingA[]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.