Optimistic Forms
poetry_optimistic_form — the predicted result paints on submit as a server-authored Turbo Stream; the server corrects only on rejection (morph refresh). One vocabulary for prediction and truth, plus the 204/no-redirect server contract.
How it works
You author the optimistic update as a Turbo Stream inside a
<template>, the same language the
server answers in. Prediction and truth are the same kind of artifact, so
there is no bespoke client-side DOM patching to keep in sync. On submit,
every optimistic_template is cloned into
the document and Turbo paints the predicted state immediately, with no round
trip. When the response arrives, one of two things happens: on success,
nothing (the optimistic paint already shows the new state, or the server's
own targeted stream corrected it); on failure, a
refresh stream is appended, Turbo
re-fetches the page, and the morph restores authoritative truth.
The happy path costs zero extra requests. A full refresh only ever runs when the server rejects the change, which is exactly when you want authority.
Setup
Reconciliation rides Turbo morph page refreshes. Add these to your layout
<head> (the install generator
reminds you):
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
Usage
A toggle predicts the toggled state while the control shows the
current one, and a prediction can target any region on the page.
Declare several optimistic_templates to
paint several regions from one submit; all of them apply.
<%# A favorite toggle: the template predicts the TOGGLED state,
the button shows the current one. %>
<%= poetry_optimistic_form(model: photo, attribute_name: :favorite, value: !photo.favorite) do |form| %>
<%= form.optimistic_template dom_id(photo, "favorite-icon"), favorite_icon(!photo.favorite) %>
<%= poetry_button(type: :submit) { favorite_icon(photo.favorite) } %>
<% end %>
<%# A submit that updates a region elsewhere on the page. %>
<%= poetry_optimistic_form(url: cart_items_path, method: :post, attribute_name: :photo_id, value: photo.id) do |form| %>
<%= form.optimistic_template "cart-count", (@cart_count + 1) %>
<%= poetry_button(type: :submit) { "Add to cart" } %>
<% end %>
The server contract
This is the part the client cannot enforce, so here it is once and
precisely. On success, respond 204 No Content
(or a targeted Turbo Stream, covered below). Never redirect: a redirect
combined with morph refreshes is itself a full page load, which defeats the
optimism you just painted. On failure, respond
4xx (typically
422) so the client reconciles. Set a
flash first if you want it surfaced after the refresh.
def update
@photo = Photo.find(params[:id])
if @photo.update(photo_params)
head :no_content
else
flash[:alert] = "Your change could not be saved."
head :unprocessable_entity
end
end
The submitted value
Two ways to carry the toggled value; pick one per form. Automatic: pass
attribute_name: and
value: to the helper and a hidden field
is injected for you. value: false is
preserved (a favorite toggle legitimately submits false); only
nil or omitted suppresses the field.
Explicit: call
form.optimistic_hidden_field :favorite, value: !photo.favorite
where you want it. That suppresses the automatic injection; the block is
captured first, so your call wins.
When the prediction can be wrong
Under contention the true result can differ from the prediction (a shared counter, a vote total). Return a targeted Turbo Stream on success instead of a 204: Turbo applies it over the optimistic guess with no client change needed. To keep prediction and truth from drifting, render the fragment from a single partial used in both places:
<%# app/views/photos/_favorite_icon.html.erb - the single source of truth %>
<span id="<%= dom_id(photo, "favorite-icon") %>"><%= favorite_icon(photo.favorite) %></span>
<%# the prediction: the same partial, opposite state %>
<%= form.optimistic_template do %>
<%= turbo_stream.update dom_id(photo, "favorite-icon") do %>
<%= favorite_icon(!photo.favorite) %>
<% end %>
<% end %>
# the authoritative success response
render turbo_stream: turbo_stream.update(
ActionView::RecordIdentifier.dom_id(@photo, "favorite-icon"),
partial: "photos/favorite_icon", locals: { photo: @photo }
)
Authoring streams directly
The positional form (target, content)
wraps a turbo_stream.update for you.
Pass a block to author any stream or several yourself:
<%= form.optimistic_template do %>
<%= turbo_stream.update("cart-count") { @cart_count + 1 } %>
<%= turbo_stream.remove(dom_id(photo, "add-button")) %>
<% end %>
When to reach for it
Use an optimistic form where the prediction is knowable and the action is
small and reversible: favorite and like toggles, add-to-cart, counters,
subscribe buttons, reorderings the user just performed. Do not use it where
the server decides the outcome (payments, permission-dependent transitions,
anything whose failure is common); a plain Turbo form with
poetry_button(loading: true) states is
the honest UI there.
Three notes. Template content is trusted developer markup, exactly like any
turbo_stream.update body: never
interpolate unsanitized user input into a prediction. Rapid resubmits are
burst-safe (the paint is throttled per form at 200ms, and the first paint is
always immediate). And the helper wires the controller and both
turbo:submit-* actions for you,
composing with any data: you pass.
The demos below run this contract live: the favorite toggle's endpoint answers 204 and is never waited on, and the rejection demo always answers 422 so you can watch the morph put truth back.
Default
<%# The favorite toggle. The optimistic_template predicts the TOGGLED
state as a turbo-stream; the button shows the current one. Submit and
the label flips instantly - the server answers 204 and is never
waited on. State rides the session, so a reload shows server truth. %>
<% favorite = session[:docs_favorite] %>
<%= poetry_optimistic_form(url: optimistic_favorite_path, method: :post, attribute_name: :favorite, value: !favorite) do |form| %>
<%= form.optimistic_template "docs-favorite",
safe_join([poetry_icon(name: favorite ? :"star-off" : :star, class: "size-4"),
favorite ? "Favorite" : "Unfavorite"], " ") %>
<%= poetry_button(variant: :outline, type: :submit) do %>
<span id="docs-favorite" class="inline-flex items-center gap-2">
<%= poetry_icon(name: favorite ? :star : :"star-off", class: "size-4") %>
<%= favorite ? "Unfavorite" : "Favorite" %>
</span>
<% end %>
<% end %>
Reconciliation
<%# The failure path, on purpose. The prediction paints "Subscribed ✓"
instantly; the endpoint always answers 422, so the controller appends
a refresh stream and the morph puts authoritative truth back - watch
the button snap home. The flash the server set survives the refresh. %>
<%= poetry_optimistic_form(url: optimistic_rejected_path, method: :post) do |form| %>
<%= form.optimistic_template "docs-subscribe", "Subscribed ✓" %>
<%= poetry_button(variant: :secondary, type: :submit) do %>
<span id="docs-subscribe">Subscribe (server will reject)</span>
<% end %>
<% end %>