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.
An application on ReActionView has a second tool. A reactive template adds a row to a list before the server answers, in markup alone, and flips a toggle at once. It does not put a change back when the server refuses it. This helper does: it answers a refusal with a refresh that restores what the server holds. Reach for a reactive template to add a row, and for this helper where the server may say no. The Herb and ReActionView guide has what each does beside Poetry's components.
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 %>