Optimistic UI: mutations, rollback, and reconciliation
Optimistic UI is the pattern where a click paints the expected result
immediately and the server response either confirms it or undoes it. Done
right it makes a server-authoritative app feel instant; done wrong it lies
to the user about state that was never committed. This doc is the
framework's contract for doing it right: one lifecycle, one state set, one
reconciliation model, composed from existing primitives: OptimisticAction,
ToggleAction, versioned sortablelist, ConfirmAction, and the generic
RPC wrappers in core-ui/interactive.
If you are looking for the wider state boundary (signals vs RPC vs polling vs SSE), start at Reactivity model or UI capability map. This page assumes that boundary and zooms in on the mutation half.
The mutation lifecycle
Every optimistic mutation in the framework follows one lifecycle. The primitives below differ in what they paint optimistically (a label, a row, a column) but they all walk the same five-state machine and reconcile against the same authoritative source: the HTTP response plus the database, never a pushed event.
capture previous projection → apply optimistic projection → send mutation (with id/version if applicable) → success (2xx): reconcile authoritative result → conflict (409 with version token): refresh authoritative region → failure (4xx/5xx/network): rollback and announce
- Capture previous projection. Before painting, the runtime keeps
enough state to undo the optimistic change. For
OptimisticActionthat is the button'sidlelabel span; for versionedsortablelistit is a snapshot of the dragged column's DOM; for an inline edit it is the input's prior value. Without a captured previous state there is nothing to roll back to. - Apply optimistic projection. The visible state flips before the
fetch resolves.
OptimisticActionswaps the label on the next animation frame;ToggleActionflips idle↔committed; the sortable runtime moves the dragged<li>into its new position immediately. - Send the mutation. One fetch to one endpoint, carrying whatever the handler needs to commit authoritatively: the new value, a stable key, a mutation/idempotency key, and (when the resource is versioned) a concurrency token.
- Reconcile. The HTTP response, not a signal and not an SSE event, is the mutation's authority. A 2xx commits the optimistic projection (or replaces it with the authoritative result); a 409 with a version token triggers server-rendered reconciliation; anything else rolls back.
The state set
| State | Meaning | Where it lives |
|---|---|---|
idle | Resting; no mutation in flight. SSR ships here. | data-state="idle" |
pending | Optimistic projection is painted; the RPC is in flight. Control is aria-busy="true" and ignores re-entry; it is not disabled, so keyboard focus stays on it. | data-state="pending" |
committed | The RPC returned 2xx. The projection is now authoritative. | data-state="committed" |
conflicted | A versioned 409. The runtime fetches fresh HTML from the conflict endpoint and replaces the affected region. (Sortable-specific today.) | data-state on the list, plus an aria-live announcement |
failed | The RPC returned non-2xx (or the network threw). The runtime rolls the projection back to idle and announces. OptimisticAction paints a brief error shake first. | data-state="error" (OptimisticAction) or direct revert to idle (ToggleAction) |
OptimisticAction and ToggleAction use data-state on the trigger
button; sortablelist uses it on the <ol> wrapper. The runtime reads
the same attribute name across primitives so a single CSS selector
([data-state="pending"]) can style every pending surface consistently.
Double-click, duplicate submit, and cancellation
The lifecycle is button-scoped and re-entry-safe. While a button is in
pending:
OptimisticActionignores further clicks (the pending/committed re-entry guard in the kernel'sactionmodule).ToggleActiondoes the same (the samedata-hui-action*guard).sortablelistignores new grabs until the active commit settles.
For mutations that must be globally idempotent across buttons, tabs,
or sessions (a "pay" button that must never charge twice), do not rely on
the button-scoped guard. Send an Idempotency-Key header so the server
deduplicates; see Idempotency. The runtime's job is to
keep one button coherent; the server's job is to keep one mutation
coherent.
Cancellation has two senses:
- User cancels the in-flight click. There is no abort; the fetch
completes and the response reconciles. The user can click a sibling
(e.g. another toggle in the same
Group) which optimistically revokes the first; the first RPC still completes, and the server stays the source of truth via a later navigation refresh. - User dismisses a confirm dialog.
ConfirmActionandAction.WithConfirmgate the request before it fires. Cancelling the dialog never sends the RPC; there is nothing to roll back.
Out-of-order responses
Each primitive keeps at most one in-flight request per trigger element.
Because the trigger ignores clicks during pending, a second click cannot
start a second fetch and arrive in a different order. The runtime does
not coordinate across triggers: if two different buttons POST to the
same handler, their responses reconcile independently against whatever
server state each finds. For ordered mutations across controls, use
version tokens (below) so the second request 409s against the first's
commit.
Temporary client IDs for optimistic inserts
An optimistic create paints a row before the server has assigned it an ID. The pattern:
- The client mints a temporary client ID (
temp:<uuid>ortemp:<timestamp>-<n>), stamps it on the optimistic row, and fires the create RPC withtemp_idin the body. - The handler commits the row, assigns the authoritative ID, and returns the row's authoritative representation (HTML fragment or JSON).
- On 2xx, the runtime replaces the temp-ID row with the authoritative row. On failure, the runtime removes the temp-ID row and announces.
The sortablelist runtime already implements the version-aware half of
this pattern (its conflict endpoint returns fresh <li> HTML that
reconciles a column, including to zero items). A general-purpose
"optimistic insert" runtime attribute is not yet shipped; today you
compose it from an island RPC that returns the authoritative row HTML and
a signal swap (data-cui-rpc-signal with data-cui-signal-mode="html").
The temp-ID discipline is a contract between your handler and your island
code; see Recipe 3.
Idempotency and mutation keys
A mutation key is a client-chosen value that says "this is logical
mutation N." Send it as the Idempotency-Key header on the fetch. The
server records the key with the commit; a replay returns the original
response instead of running the mutation again. This is what makes a
flaky network safe for "ship order," "send invoice," "charge card": the
user can refresh, the browser can retry, the runtime can re-fire, and the
server deduplicates. See Idempotency for the
server-side contract.
For purely visual optimistic actions (follow, like, save-tag) a mutation key is optional; the worst case is a double-toggle that the next navigation refresh corrects. For anything that creates, charges, or notifies, send one.
Version and ETag conflict tokens
When a resource can be edited by two actors at once, send a version
token (an ETag, a version=N counter, an updated_at cursor) along
with the mutation. The handler compares the request's token to the row's
current token; on mismatch it returns 409 Conflict in the error
envelope every route answers with. The runtime then reconciles instead of rolling back.
sortablelist is the canonical implementation. Each commit POST carries
version=<token> (from SortableListConfig.Version); a 409 response triggers
ConflictRPC (from SortableListConfig.ConflictRPC), which returns fresh
server-rendered <li> HTML that replaces the destination column. The
409 body is read under hard safety bounds: Content-Type: application/json,
at most ~4 KB, must parse as the error envelope {"error": "…"}, and the
error message, when a string, is surfaced through the polite aria-live region.
See Recipe 5 and
Interactive patterns → Sortable List.
Rollback vs authoritative refresh
Two distinct reconciliation paths, do not confuse them:
- Rollback. The runtime restores the captured previous projection. Used when there is no server-side truth to reconcile against: a network failure, a 4xx validation error, a non-versioned 5xx. The UI returns to exactly what it showed before the click.
- Authoritative refresh. The runtime fetches the server's current state and replaces the optimistic region with it. Used when the server knows the projection is stale: a versioned 409, or any path where the handler can return fresh HTML. The UI lands on server truth, which may differ from both the optimistic projection and the prior state.
Rule of thumb: rollback is local and exact; authoritative refresh is round-tripped and possibly surprising. Prefer authoritative refresh when the server has a coherent answer; prefer rollback when the server is unreachable or has no opinion.
Accessible announcements
Pending, success, and failure must reach assistive tech, not just sighted users. The primitives handle this differently:
aria-busyis set on the trigger duringpendingby bothOptimisticActionandToggleAction, and removed on settlement.aria-pressedmirrors committed state onToggleAction(it is a toggle by WAI-ARIA's contract).OptimisticActiondoes not usearia-pressed; it is one-shot, not a pressed/unpressed control.prefers-reduced-motionis respected: theOptimisticActionshake animation is disabled under@media (prefers-reduced-motion: reduce); the visible label flip is unchanged because it carries information, not decoration.aria-liveregions announce sortable grab/move/rollback/conflict events (the polite region is wired into theheadless-sortablelistmodule).
The two button primitives (OptimisticAction, ToggleAction) do not
today emit a spoken "Saved" / "Rolled back" announcement; they rely on
the visible label flip and aria-busy toggling. See Consistency
notes for the gap and the workaround.
SSE is not the durable ledger
A common mistake is to wait for an SSE event to confirm a mutation. The runtime never does this and your handler should not either. SSE is push-only: best-effort delivery for other sessions to learn that state changed. The initiating request's HTTP response is the mutation's authority; the database row is the durable truth. SSE can drop, lag, or arrive in any order relative to the response; treating it as the confirmation channel inverts the contract.
Concretely:
- After a successful follow POST, the
ToggleActionis committed because the response was 2xx, not because auser.followedSSE event arrived. - After a sortable move, the column is authoritative because the move
POST returned 2xx (or the 409 conflict path reconciled), not because
a
board.movedevent was broadcast. - Other tabs and other users learn about the change through SSE; the initiating tab learned through the response.
UI capability map → Delivery guarantees and Events and SSE cover the wider lane semantics.
The seven recipes
Each recipe names the primitive, the handler contract, the response shape
the runtime expects, and where to see it running in examples/site
(go run ./examples/site then open the listed route). Demos that are
already complete are linked, not rebuilt.
Recipe 1: binary toggle / follow
Use for: binary server-backed state the user flips in place: follow / following, watch / unwatch, subscribe / unsubscribe, default-plan picker.
Primitive: ui.ToggleAction (framework/ui/toggleaction.go). Runtime:
the kernel's action module through the data-hui-action* hooks. SSR ships
the initial state via Committed; the runtime mirrors it onto aria-pressed
and flips idle↔committed on click.
A second click reverts when AllowUntoggle (or UntoggleEndpoint) is
set; without it the button is sticky once committed, matching
OptimisticAction.
Compose:
ui.ToggleAction(ui.ToggleActionConfig{ Endpoint: "/api/follow/42", UntoggleEndpoint: "/api/unfollow/42", IdleLabel: "Follow", CommittedLabel: "Following ✓", Committed: alreadyFollowing, // SSR initial state AllowUntoggle: true, })
Handler contract: POST /api/follow/42 → 2xx on success, non-2xx on
failure (runtime reverts to idle). The body is empty; the resource id is
in the URL. Authorize in the handler; never trust the client's optimistic
state.
Authorization: the trigger renders for any user who can see the page; the handler must re-check the session and the follow relationship. The optimistic flip is presentation only.
Runnable proof: /components/toggleaction.
E2E: TestE2E_ToggleAction_CommitUntoggle,
TestE2E_ToggleAction_GroupMutex in
examples/site/e2e_new_components_interactions_test.go.
Recipe 2: inline edit with rollback
Use for: editing one field of a record in place, whether rename, retitle, or edit a tag, where the field has a known previous value and the server validates the new one.
Primitive: ui.OptimisticAction (framework/ui/optimisticaction.go)
as the commit trigger, paired with a text field whose prior value is the
rollback target. Runtime: the kernel's action module through the
data-hui-action* hooks. The button flips to its
success label optimistically, fires its endpoint, and on non-2xx shakes
and reverts. The error shake animation respects prefers-reduced-motion.
Honest gap.
OptimisticAction's fetch is fire-and-forget; it does not serialize form fields. For a true inline edit where the new value must reach the handler, wrap the input in a<form>and useinteractive.OnSubmit; the runtime submits the form as JSON keyed by each control'sname. The two patterns compose: the form transmits the value, an adjacentOptimisticActionprovides the optimistic visual on a value-independent commit (a "freeze" toggle, a "revert to draft" action). For a one-form-one-button edit, preferOnSubmitplusinteractive.AfterText("Saved ✓"); see Forms.
Compose (the value-independent optimistic half):
ui.OptimisticAction(ui.OptimisticActionConfig{ Endpoint: "/api/rename/validate", IdleLabel: "Save", SuccessLabel: "Saved ✓", })
Handler contract: POST /api/rename/validate → 2xx if the (server-
known) pending value is acceptable, 4xx otherwise. The runtime shakes +
reverts on non-2xx. To transmit the actual edited value, use a sibling
interactive.OnSubmit form posting to /api/rename whose response
swaps the display region (data-cui-rpc-signal with mode=html).
Authorization: the input and the button render for any viewer; the handler enforces "can edit this field." A 4xx response is the auth path the runtime understands: return 4xx (not a redirect) when the user lacks permission, and the optimistic flip rolls back honestly.
Runnable proof: /components/optimisticinlineedit.
The demo pairs a text input with two OptimisticAction buttons: one
hits an endpoint that returns 2xx (the optimistic commit sticks), one
hits an endpoint that returns 4xx (the button shakes and reverts). E2E:
TestE2E_Optimistic_InlineEdit_SuccessAndRollback.
Recipe 3: optimistic create with a temporary row
Use for: adding a row to a list, a comment, a task, a tag, where the user expects the new row to appear instantly and the server assigns the durable ID.
Primitive: interactive.OnClick + data-cui-rpc-signal (mode
html) for the round-trip; the temp-ID discipline is a contract between
your handler and the row template you render. There is no general-purpose
"optimistic insert" runtime attribute today; you compose it.
Compose (the closest existing-primitive demo):
// Render: a list region bound to a signal, and an Add button whose // RPC swaps that region with authoritative HTML on 2xx. html.Div(html.DivConfig{ ExtraAttrs: html.Attrs{ "data-cui-signal": "opt-create-list", "data-cui-signal-mode": "html", }, }, renderOptItemList()) interactive.OnClick( ui.Button(ui.ButtonConfig{Label: "Add item"}), interactive.Post("/__site/optimistic/create"). OnSuccess(interactive.SetSignal("opt-create-list")), )
Handler contract: POST /__site/optimistic/create appends a row to
server-side state and responds with the full, authoritative list HTML
(not just the new row). The runtime replaces the list region's
innerHTML with the response. Because the response is the entire list,
the new row appears with its real server-assigned id; there is no
temp-row-to-reconcile on the wire. On a non-2xx response the runtime
broadcasts the auto-built error object {ok:false,status,text} into
the signal; an html-mode region ignores non-string values (the
optimistic-UI invariant: a failed create leaves the list exactly where
it was), while a sibling text-mode status node renders a
human-readable "Error: <status> — <text>" line.
The temp-row pattern (what "optimistic" adds): a true optimistic
create paints the row before the fetch resolves. That requires an
island with a small amount of registered JS that mints a temp:<id>
row, fires the RPC, and swaps the temp row for the authoritative one on
2xx (the retired data-cui-optimistic-* family never shipped; the
headless action contract — data-hui-action* — announces the trigger's
busy/committed/rollback states, and the temp-row swap stays the
island's). The pattern's invariants:
- Mint a temp id only the client will see; never let the server persist it.
- Send
temp_idin the request body so the handler can correlate. - The 2xx response carries the authoritative row (with the real id); a failure removes the temp row.
- A temp row must not be editable, draggable, or deletable until the authoritative replacement lands; otherwise a user can mutate state that does not exist yet.
Authorization: the Add button can render for everyone; the handler enforces "can create." A 4xx on create should leave the list unchanged (response body empty or an error fragment the runtime writes into the signal region).
Runnable proof: /components/optimisticcreate.
The demo exercises the round-trip shape: click Add → the list region
swaps with fresh server HTML showing the new row. E2E:
TestE2E_Optimistic_Create_AppendsAndPersists.
Recipe 4: optimistic delete with restore
Use for: removing a row: delete comment, remove member, archive task,
where the user expects the row to disappear and the server confirms.
Pair with ConfirmAction when the action is destructive enough to need a
second click.
Primitive: ui.ConfirmAction for both the gate and the round-trip.
Pass SuccessSignal: "<list-signal>" and the Confirm button emits
data-cui-rpc-signal="<list-signal>"; on 2xx the runtime broadcasts
the response body into every data-cui-signal="<list-signal>"
data-cui-signal-mode="html" region, swapping in the authoritative
shorter list. The "restore" falls out of the model: the list is not
swapped until 2xx, so a failure leaves the row exactly where it was.
Compose:
// One mounted modal per <trigger, modal> pair (see ui.ConfirmAction). trigger, modal := ui.ConfirmAction(ui.ConfirmActionConfig{ Name: "delete-item-" + item.ID, // unique per row TriggerLabel: "Delete", Title: "Delete this item?", Body: "It will be removed permanently.", RPCPath: "/__site/optimistic/delete?id=" + item.ID, SuccessSignal: "item-list", // ← swaps the list region on 2xx }) // widget.Mount(app.Router(), modal.Build()), once, at startup // Render the trigger inline next to its row; the modal mounts globally. trigger
Bind a list region to the same signal name so the runtime has somewhere to apply the response:
listRegion := html.Div(html.DivConfig{ ExtraAttrs: html.Attrs{ "data-cui-signal": "item-list", "data-cui-signal-mode": "html", }, }, renderItems())
Handler contract: POST /__site/optimistic/delete?id=<id> removes
the row and responds with the authoritative list HTML (the list
without the deleted row). On 2xx the runtime swaps the list region; on
non-2xx the runtime broadcasts the auto-built error object
{ok:false,status,text} into the signal, but an html-mode region
ignores non-string values; the list stays byte-identical and the
row stays visible. Pair SuccessSignal with a text-mode sibling
region if you want a human-readable failure message; the html region is
reserved for trusted-HTML payloads and is never corrupted by an error
object.
Restore semantics. Two flavors:
- Implicit restore on failure. The row never visually disappeared; the swap happens on 2xx, so a failed delete leaves the row in place. This is what the existing primitives give you for free.
- Explicit undo window. A truly optimistic delete removes the row instantly and shows an "Undo" affordance for N seconds; if the user clicks Undo (or the RPC fails), the row reappears. This pattern needs the same temp-row machinery as Recipe 3 and is not directly supported by a single runtime attribute today; compose it as an island.
Authorization: ConfirmAction's trigger renders for any viewer; the modal is the UX layer, not the auth layer. The handler must re-check "can delete this row" and return 4xx (not a redirect) when the user lacks permission; the runtime leaves the row in place, which is the honest outcome.
Runnable proof: /components/optimisticdelete.
The demo shows a small list where each row's Delete button opens the
ConfirmAction modal; confirming hits the delete endpoint and the list
swaps to the authoritative shorter list on 2xx. A "Delete n1 (will
fail)" affordance below the list confirms into a 422 endpoint so you
can see the failure invariant hold: the list does not change. E2E:
TestE2E_Optimistic_Delete_RemovesOnConfirm (success) and
TestE2E_Optimistic_Delete_Fail_LeavesListUnchanged (422 → list
byte-identical, row still present).
Recipe 5: sortable/Kanban move with version-aware 409
Use for: reordering within a list, or moving cards between columns on a board, where two users can move the same item concurrently.
Primitive: framework/ui.SortableList with SortableListConfig.Version
(the concurrency token) and SortableListConfig.ConflictRPC (the reconciliation
endpoint; ui.SortableListItems renders the fresh rows it returns).
Runtime: the registered headless-sortablelist module.
Compose: one ui.SortableList per column, all sharing the same
Group (the board id), each with a unique Container (the column id).
Version is appended to every commit POST as version=<token>. A 409
response triggers ConflictRPC (a GET), whose response body replaces the
destination column's innerHTML.
ui.SortableList(ui.SortableListConfig{ Label: col.Title, Group: "board-1", Container: col.ID, RPCPath: "/api/board/move", Version: fmt.Sprintf("v%d", board.Version), ConflictRPC: "/api/board/conflict?container=" + col.ID, Items: items, })
Handler contract.
POST /api/board/move: body hasorder,moved,container,version. Ifversiondoes not match the current board version, respond 409 Conflict withContent-Type: application/jsonand the error envelope{"error":"…","code":409,"reason":"…"}(handler.WriteErrorwrites it from ahandler.Failorhandler.Errorf; theerrortext is surfaced through the politearia-liveregion, capped at ~300 chars). On match, apply the move, bump the version, respond 2xx.GET /api/board/conflict?container=<col>: return fresh<li>HTML for the named column at its current authoritative state. An empty body reconciles the column to zero items.
The runtime reads the 409 body under hard safety bounds
(Content-Type: application/json, ≤ ~4 KB, must parse, error ≤ ~300
chars). Anything malformed falls back to the generic copy. Without
Version, a 409 is treated like any other non-2xx (plain rollback);
back-compat.
Authorization: the rendered board reflects what the viewer can see; the move handler must re-check that the user can move the named card into the named column. A 403 (treated as non-2xx) rolls the DOM back; a 409 with version mismatch reconciles. Use 409 only for genuine concurrency conflicts, never for authorization failures.
Runnable proof: /components/sortablelist,
a 3-column kanban backed by the package-level kanbanBoard store, with
/__site/sortable/move (version-aware 409) and
/__site/sortable/conflict (fresh HTML). E2E:
TestE2E_SortableKanbanDragPersist in
examples/site/e2e_sortable_kanban_test.go. Full handler source:
examples/site/main.go (search sortable/move).
Recipe 6: grouped mutually-exclusive selection
Use for: picking exactly one of N: plan picker, status selector, the "which list does this belong to" radio-like control rendered as buttons.
Primitive: ui.ToggleAction with a shared Group key. Runtime:
the kernel's action module through the data-hui-action* hooks
(data-hui-action-group). Committing any button in the group optimistically
reverts the previously-committed sibling (no extra RPC; the server stays
the source of truth and a later navigation refreshes from server state).
Compose:
ui.Cluster(ui.ClusterConfig{Gap: ui.GapSM}, ui.ToggleAction(ui.ToggleActionConfig{ Endpoint: "/api/plan/free", IdleLabel: "Free", CommittedLabel: "Free ✓", Group: "plan-picker", Committed: currentPlan == "free", }), ui.ToggleAction(ui.ToggleActionConfig{ Endpoint: "/api/plan/pro", IdleLabel: "Pro", CommittedLabel: "Pro ✓", Group: "plan-picker", Committed: currentPlan == "pro", }), )
Handler contract: each button POSTs its own endpoint on commit. The
Group mutex is purely client-side; the server sees two independent
mutations. If the user clicks Pro then immediately clicks Free before
either RPC settles, both fire; the server must treat them as the
independent idempotent writes they are (or apply the version-aware 409
pattern from Recipe 5 to make the second reject). The client still
converges on one committed button: the revoke runs again when a commit
settles, so the last completer wins and the other ends idle. On a
navigation refresh, SSR re-reads currentPlan from the database and
renders exactly one button as Committed, so client and server
reconverge.
Authorization: each per-plan endpoint enforces its own authorization. A user who can read "Pro" but not select it gets a 4xx on the Pro POST; the failed button rolls back to idle and the sibling it displaced is restored to committed on the spot (the server never accepted the new member, so the old one is still the committed one).
Runnable proof: the "Free / Pro" cluster on
/components/toggleaction. E2E:
TestE2E_ToggleAction_GroupMutex.
Recipe 7: slow / network-failure behavior and retry
Use for: every optimistic surface: what the user sees when the network is slow, the server is down, or the response never comes. This recipe is not a separate component; it is the failure path every other recipe must survive.
Primitive: the error/idle revert path in the kernel's
action module (bound through the data-hui-action* hooks the
headless action primitives render), the rollback in
the registered headless-sortablelist module, and ui.NetworkRetryBanner for the global "you
appear to be offline" surface.
What happens on failure:
OptimisticActionpaints theerrorstate (shake animation, disabled byprefers-reduced-motion), then reverts toidleafter ~600 ms. It dispatchesoptimistic-action:rolled-backso app code can hook in.ToggleActionfails the same way now that both ride the kernel'sactionprimitive: a failed commit paintserror, dispatchesaction:rolled-back, and reverts toidleafter ~600 ms. A failed untoggle is the one silent path: the state stayscommitted, because the revert was refused and there is nothing to roll back to. Theui-toggle-actionstylesheet ships no shake keyframes, so the toggle'serrorwindow is a pause, not a shake.sortablelistrestores the destination column from its captured snapshot. WithVersionset, a 409 takes the conflict-refresh path instead; without it, any non-2xx rolls back.NetworkRetryBannershows when the framework reports the connection lost with a retry scheduled (the headless module followswindow.__gofastr.sseStatus; the failure-count and SSE-silence triggers retired with the old module). It hides when the Retry button's health-check returns 2xx, or when app code callswindow.__gofastr.networkStatus.reportRecovery(). It does not wrapwindow.fetch; apps wirereportFailure/reportRecoveryinto their own RPC error handlers.
Retry. The primitives do not auto-retry; they roll back and let the user try again. For mutations that should retry transparently (sync, upload, batch), use a durable queue with at-least-once delivery (Job queue) and idempotency keys (Idempotency) so a retry cannot double-apply.
Compose (a demo that exercises the failure path):
ui.OptimisticAction(ui.OptimisticActionConfig{ Endpoint: "/__site/optimistic/fail", // always 4xx IdleLabel: "Save (will fail)", SuccessLabel: "Saving…", })
Handler contract for the demo: POST /__site/optimistic/fail →
422 (or any non-2xx). POST /__site/optimistic/slow → 2xx after a
~2 s delay (exercises the pending window). The runtime shakes + reverts
on the fail endpoint; commits after the delay on the slow endpoint.
Authorization: a 401/403 is a non-2xx like any other; the optimistic projection rolls back. Pair with the global auth redirect at the page level, not the per-button level; the button should never be asked to handle a session expiry.
Runnable proof: /components/optimisticslow.
The demo pairs a slow endpoint (exercises pending then commits) with a
fail endpoint (exercises error then rolls back) and renders a
NetworkRetryBanner. E2E: TestE2E_Optimistic_Slow_PendingThenCommit,
TestE2E_Optimistic_Fail_RollsBack.
Consistency notes
This section audits the four primitives that participate in optimistic
mutations, interactive.OptimisticUpdate, ui.OptimisticAction,
ui.ToggleAction, and versioned sortablelist, for behavioral
consistency. Applications composing these into the recipes above should
know where they agree and where they diverge.
Agreed everywhere
pendingmarks the trigger busy and ignores re-entry. The action primitive setsaria-busy="true"duringpendingand clears it on settlement; a click while pending is a no-op. It never setsdisabled: a disabled button drops keyboard focus to the body, anddisabledhas other owners (a hidden conditional region disables its controls) whose decision a settlement must not undo.- CSRF forwarding. All fetches forward
<meta name="csrf-token">asX-CSRF-Token. The handler is responsible for verifying the token; the runtime just makes it available without per-call-site plumbing. - Button-scoped re-entry safety. Each trigger keeps at most one in-flight request. A second click cannot start a second fetch on the same element, so responses cannot arrive out-of-order within one button.
prefers-reduced-motion. The OptimisticAction shake animation is disabled under the media query. The visible label flip is unchanged because it is informational, not decorative.
Known inconsistencies
These are real gaps. They are documented here rather than silently papered over; each is a medium-sized change (runtime + Go + tests) that should be undertaken deliberately, not as a side effect of a docs pass.
ToggleAction's failure indication stops short of a shake. Both buttons ride the kernel'sactionprimitive, so a failed commit on either paintsdata-state="error", dispatchesaction:rolled-backand reverts after ~600 ms — but only theui-optimistic-actionstylesheet ships shake keyframes, so a failedToggleActionpauses where a failedOptimisticActionshakes.framework/headless's buttons announce the rollback in a status span either way; the styled pair still rely on the visible flip.
- No spoken announcement of commit in the styled layer.
sortablelistannounces grab/move/rollback/conflict through a politearia-liveregion.OptimisticActionandToggleActiondo not announce the commit; they rely on the visible label flip andaria-busytoggling. The custom events are available for app code to write into anaria-livespan: the primitive'saction:start,action:committed,action:rolled-backandaction:untoggleon every bound button, plus the component namesoptimistic-action:*andtoggle-action:commit/toggle-action:untogglethe adapters re-dispatch.framework/headless's failure span is the shipped version of that pattern for the headless pair.
OptimisticActionis fire-and-forget;ToggleActionis too. Neither serializes form data. For mutations that must transmit a value, useinteractive.OnSubmit(which keys the JSON body off each control'sname). This is by design: keeping the optimistic primitives payload-free makes them composable, but it is the reason Recipe 2 reaches for two patterns side by side. Documented here so the next author does not look for a "body" config field that does not exist.
Choosing among the four
- One-shot commit, no payload, no untoggle →
ui.OptimisticAction(orinteractive.OptimisticUpdatefor the lower-level wrapper). - Binary state, flippable, optionally grouped mutex →
ui.ToggleAction. - Reorder or cross-column move with concurrency → versioned
sortablelist. - Anything that must transmit form data →
interactive.OnSubmit, optionally paired with one of the above for the optimistic visual.
See also
- Interactive patterns: the full
data-cui-*vocabulary, including the sortable list and ConfirmAction reference. - Reactivity model: the four ways to make a page change after first paint (signals, RPC, polling, SSE) and the statelessness contract.
- UI capability map: the wider state boundary and where optimistic state lives.
- Runtime contract: the SSR/hydration/island/SSE
model and the
data-cui-*attribute reference. - UI composition recipes: page grammar for the surfaces these recipes compose into.
- Idempotency: the server-side
Idempotency-Keycontract that makes optimistic mutations safe to retry. - Events and SSE: why SSE is push-only and is never the mutation's confirmation channel.
Common mistakes
- Waiting for SSE to confirm a user action. The HTTP response is the authority; SSE tells other sessions. Treat the response as the commit signal.
- Rolling back without capturing the previous projection. If your
handler returns non-2xx, the runtime needs somewhere to roll back to.
For
OptimisticActionthat is the idle label; for a custom island, you must keep the prior HTML/value yourself. - Treating a 409 as a generic failure. Without
Versionit is; withVersionit triggers server-rendered reconciliation. Use 409 only for genuine version conflicts, never for authorization (that is 401/403). - Sending a temp id to the database. A temp id is a client-only correlation key. The handler mints the real id; the response carries it back; the runtime replaces the temp row.
- Letting the user edit a temp row. A temp row is not yet real. Make it non-interactive until the authoritative replacement lands.
- Authorizing at the trigger. The trigger renders for viewers; the handler enforces. A 4xx response is the runtime's honest "rolled back" signal; return it instead of redirecting when permission is missing.
- Adding a second styling surface for the optimistic states. The
[data-state="pending|committed|error"]selectors are the styling surface; compose them, do not invent parallel classes (Hard rule 7).