Skip to content

Optimistic UI

A mutation should feel instant. optimistic() from @webjsdev/core paints the expected result of a create, update, delete, like, toggle, or reorder before the server confirms it, runs the real server action underneath, and releases the optimistic overlay when that action settles. It is the default for every user-facing mutation whose result the client can predict from the input.

The rule that follows from that: never hand-roll try-catch, cache-and-restore, or temp-id reconciliation when one of the two signatures below covers the pattern. Those hand-rolled versions are where the subtle bugs live (a rollback that fires twice, an overlapping mutation that clobbers its neighbour, a temp id that outlives its row), and all three are already solved here.

Two signatures, one export

optimistic is a single import with two call shapes, picked apart at runtime by what you pass first.

  • Declarative, optimistic(host, { source, update }). A React 19-style queue of pending updates attached to a component. Reach for this for collections, which is most mutations.
  • Imperative, optimistic(signal, value, action). A thin wrapper that flips a signal, awaits the action, and restores the previous value on failure. Reach for this only when the mutation is a single value, typically a boolean.

Both are client-only, because both do client work (the declarative form calls host.requestUpdate(), the imperative form writes a signal). A component that imports optimistic is therefore never elided as a display-only component, and its module always ships to the browser. See Display-Only Elision.

Declarative: optimistic(host, { source, update })

The call returns an OptimisticState with a .value getter and an .add() method. source reads the authoritative state, usually a reactive prop. update is a reducer folding one queued payload into that state. Calling .add() pushes a payload and schedules a re-render, so the next paint reads the optimistic value.

import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
import type { Todo } from '#db/schema.server.ts';  // type-only, erased before the browser

class TodoList extends WebComponent({ todos: prop<Todo[]>(Array) }) {
  // A prop with no seeded value and no declared default is undefined, so give
  // it one here. The page below seeds real rows through a .todos prop hole.
  constructor() { super(); this.todos = []; }

  private optimisticTodos = optimistic(this, {
    source: () => this.todos,
    // KEEP THIS REDUCER PURE. See the section below.
    update: (state, add: { tempId: string; title: string }) => [
      ...state,
      { id: add.tempId, title: add.title, completed: false, pending: true },
    ],
  });

  async handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    const form = e.target as HTMLFormElement;
    const title = new FormData(form).get('title') as string;
    if (!title) return;
    form.reset();

    // Minted ONCE here, not in the reducer.
    const tempId = crypto.randomUUID();
    const promise = createTodo({ title });
    this.optimisticTodos.add({ tempId, title }, promise);  // auto-releases on settle

    const result = await promise;
    if (result.success && result.data) {
      // Reconcile: append the server's canonical row.
      this.todos = [...this.todos, result.data];
    }
  }

  render() {
    // The form binds the action AND calls the handler, so it submits with JS
    // off and runs the optimistic path with JS on. See "degrade-first" below.
    return html`
      <form action=${createTodo} @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>
        <input name="title" required>
        <button>Add</button>
      </form>
      <ul>${this.optimisticTodos.value.map(todo => html`
        <li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
      `)}</ul>`;
  }
}
TodoList.register('todo-list');

Keep the reducer pure

This is the one rule that bites, so it is worth stating plainly. .value re-folds the entire queue on every read, not once per .add(). Your update reducer therefore runs again on each render, and anything it mints is minted again each time.

A crypto.randomUUID() inside the reducer hands the pending row a different id on every read. A keyed list (repeat(todos, t => t.id, ...)) sees a new key each update and tears the row down and rebuilds it, losing focus, any in-progress transition, and DOM state. A hardcoded 'tmp' is no better, because two concurrent adds collide on it. Mint the temp id in the handler, carry it in the payload, and the row keeps one stable identity for its whole life.

The same applies to anything else derived at fold time. A createdAt: new Date() in the reducer is rebuilt per read too, which is tolerable only for as long as nothing keys on it or renders it as a stable string. Put it in the payload as well the moment something does.

Auto-release, and what rollback actually means

Pass the action's promise as the second argument to .add(payload, promise) and the entry auto-releases the moment that promise settles, on resolve and on reject. Internally that is a .finally(), with a .then() fallback for thenables that lack it.

Worth being precise about what happens on failure, because "rolls back" undersells it. The declarative form holds no copy of your state. The overlay stores only the payloads, and .value rebuilds the optimistic view from source() on every read. So when the promise rejects, the entry drops and the next paint reads authoritative state again, with nothing to restore and nothing to unwind. That is also why the success path needs an explicit reconcile: the optimistic row was never written to this.todos, so you append the server's canonical row from result.data, matching the order your reducer used.

  • Concurrent adds stack. Each entry carries its own release keyed by id, so overlapping in-flight mutations never clobber one another.
  • .add() returns its own release(). Call it by hand when there is no promise to hand over, for example an overlay you clear on a later user action rather than on a network result.
  • Omitting update replaces the state. With no reducer the payload becomes the value directly (Action is State), and with several queued the last one wins. This matches the plain useOptimistic(setState) shape.

Author it as a degrade-first form

That is what the <form> above is doing, and it is worth naming as a pattern. Wrap the mutation in a real form bound to the action, then intercept it for the optimistic path. One form serves both ends. With JS off the browser submits and the server dispatches to that action, which is the no-JS write path. With JS on, @submit calls e.preventDefault() and runs the optimistic path instead. Note the arrow wrapper on that listener. An @event handler is not bound to your component, so a plain method passed directly would see a framework-internal object as this and fail quietly rather than throw.

The same imported function is both the form binding and the optimistic path's callee, which is what makes this cheap: there is no second wiring to keep in step. method and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so nothing there is yours to manage. A fetch-only @click handler is the shape to avoid, because it has no no-JS half at all. See Progressive Enhancement.

When a page owns several mutations, give each form its own binding (action=${createTodo}, action=${toggleTodo}, action=${deleteTodo}), or bind each submitter with formaction=${action} so one form can drive several actions. A bound submitter carries its own submission and asks nothing of the form around it.

Seed the list from the server for SSR plus optimistic

For a page that server-renders a list and lets the user add to it, let one component own both the list and the form, and seed it from the page through a .prop hole (a DOM property that round-trips through SSR on custom elements). The list is then fully server-rendered on first paint, readable with JS off, and re-renders optimistically on each add.

// app/notes/page.ts (server-only; awaits the data so it is in the first paint)
import { html } from '@webjsdev/core';
import '#modules/notes/components/note-composer.ts';  // registers <note-composer>
import { listNotes } from '#modules/notes/queries/list-notes.server.ts';

export default async function NotesPage() {
  const notes = await listNotes();
  return html`<note-composer .notes=${notes}></note-composer>`;
}

The component reads that seeded prop as its source, so source: () => this.notes is both the SSR'd list and the base for optimistic additions. Rendering a separate static list in the page would not update on an optimistic add, because a page never hydrates.

Imperative: optimistic(signal, value, action)

For a boolean flip where the value itself is the mutation (like, follow, pin), the imperative form is a thin wrapper over the signal primitive. It sets the signal, awaits the action, and restores the previous value on failure.

import { WebComponent, prop, signal, optimistic, html } from '@webjsdev/core';
import { likePost } from '#modules/posts/actions/like-post.server.ts';

class LikeButton extends WebComponent({ postId: prop(String) }) {
  // INSTANCE scope: one signal per element. A module-scope signal() is SHARED
  // across every instance, so a feed of these would all flip on one click.
  private liked = signal(false);

  private async toggle() {
    const next = !this.liked.get();
    // Returns the action's ActionResult; the rollback already happened by then.
    return optimistic(this.liked, next, () => likePost(this.postId));
  }

  render() {
    return html`<button @click=${() => this.toggle()}>
      ${this.liked.get() ? 'Liked' : 'Like'}
    </button>`;
  }
}
LikeButton.register('like-button');

Note the scope. A module-scope signal() is shared by every component instance, which is exactly right for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag) and exactly wrong for a per-item flip, where a list of buttons would all light up on one click. Per-item state goes on the instance, as above. liked is a plain instance field rather than a reactive property declared in the factory, so the class-field rule that protects reactive props does not apply to it.

Two failure modes restore the previous value. A throw from the action rolls back and then re-throws, so a caller that wants to react still has to catch it. A returned { success: false } envelope rolls back and is returned rather than thrown, so you read its error or fieldErrors off the result.

One sharp edge is worth knowing. That second check tests result.success === false exactly, which is stricter than the general ActionResult failure rule described in Server Actions, where a bare fieldErrors or error key also counts as failure. An action that returns { fieldErrors: { ... } } and omits success therefore leaves the optimistic value in place. Return an explicit success: false from any action you drive this way, which is the shape to write regardless.

When optimistic UI is appropriate

  • Todo items, comments, posts, likes, follows, toggles, reorders, renames, status changes.
  • Any mutation where the client can construct the expected result from the input.
  • CRUD where the server returns the same shape the client already holds.

When to skip it

Optimistic UI is a lie the client tells briefly and then makes true. Skip it wherever the client cannot honestly predict the ending.

  • The result is unpredictable. AI-generated content, server-computed values, anything the client would have to invent. Show a pending state instead.
  • The user must wait for a side effect. Payment processing, email sending, an OAuth round trip. Pretending it is done is worse than showing it in progress.
  • The action validates against data that may have moved. Unique constraints and race conditions produce a rollback the user reads as a glitch, because the row they watched appear vanishes a moment later.
  • The mutation is destructive with no undo. A confirm-first flow is the better UX, and an optimistic delete that fails leaves the user unsure what state their data is in.

Rules

  1. Default to optimistic() for every predictable user-facing mutation.
  2. Prefer the declarative .add(payload, promise) form for list mutations, and pass the promise so release is automatic.
  3. Keep the update reducer pure, and mint temp ids in the handler.
  4. Use the imperative form only for a single value whose flip is the mutation.
  5. Never hand-roll try-catch, cache-and-restore, or temp-id reconciliation when these APIs cover the pattern.
  6. Reconcile the authoritative row from the returned ActionResult once the promise settles.

Server Actions covers the actions these calls invoke and the ActionResult envelope they return. Data Fetching covers the read side. Client Router covers how a bound form's response is applied in place, which is what the no-JS half of a degrade-first form falls back to.