Optimistic
stimeo--optimistic
Optimistic UI for Turbo forms: apply instantly, keep on success, roll back precisely on failure.
The stimeo--optimistic controller wraps a Turbo form and applies a DECLARED optimistic state the instant submission starts: show targets are revealed, hide targets are hidden, and the form gets data-optimistic="true" plus aria-busy="true". On a successful turbo:submit-end the toggle is kept and commit fires — the final DOM belongs to the server's Turbo Stream response or redirect. On failure exactly the changes THIS controller made are restored and rollback fires: every target it actually toggled is marked with data-optimistic-toggled, so an element the author already showed is never clobbered. It rides Turbo's own submit lifecycle events with two delegated listeners — no Action Cable, no fetch patching — which is why it lives in the zero-dependency core rather than stimeo-ui/cable. Pairs naturally with submit-once (double-submit guard) and live-counter (optimistic numbers). Declarations beyond show/hide (classes, text swaps) are the consumer's, via the commit/rollback events; user-facing error display on rollback belongs to the form's error UI.
Both buttons flip to "Liked" the instant you click (each button submits a real Turbo form). The first one succeeds, so the flip is kept (and the server's Turbo Stream fills the status line). The second posts fail=1 which the server rejects with 422 — watch the flip roll back.
<%# optimistic: wraps a real Turbo form POST. On turbo:submit-start the hidden
"Liked" face shows and the "Like" face hides IMMEDIATELY (each toggled
target is marked); on success the toggle is kept (commit — the final DOM
belongs to the server's Turbo Stream response, which updates the status
line below), on failure exactly the marked toggles are rolled back. The
second form posts fail=1, which the demo endpoint rejects with 422, so you
can watch the rollback. The hidden authenticity_token is Rails' CSRF token
(form_with renders it automatically in a real app). %>
<div class="optimistic-demo">
<p class="optimistic-demo__hint"><%= t("components.optimistic.demo.hint") %></p>
<div class="optimistic-demo__row">
<form class="optimistic-demo__form" method="post"
action="<%= optimistic_demo_likes_path %>"
data-controller="stimeo--optimistic">
<%= hidden_field_tag :authenticity_token, form_authenticity_token, id: nil %>
<button type="submit" class="demo-trigger optimistic-demo__button">
<span class="optimistic-demo__face" data-stimeo--optimistic-target="hide">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.like") %>
</span>
<span class="optimistic-demo__face" hidden data-stimeo--optimistic-target="show">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.liked") %>
</span>
</button>
</form>
<%# Same wiring, but the server rejects it (fail=1 → 422): the optimistic
toggle appears for the request's duration, then rolls back. %>
<form class="optimistic-demo__form" method="post"
action="<%= optimistic_demo_likes_path %>"
data-controller="stimeo--optimistic">
<%= hidden_field_tag :authenticity_token, form_authenticity_token, id: nil %>
<input type="hidden" name="fail" value="1">
<button type="submit" class="demo-trigger optimistic-demo__button">
<span class="optimistic-demo__face" data-stimeo--optimistic-target="hide">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.like_fail") %>
</span>
<span class="optimistic-demo__face" hidden data-stimeo--optimistic-target="show">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.liked") %>
</span>
</button>
</form>
</div>
<%# The server's Turbo Stream response updates this line (success and 422 alike). %>
<p id="optimistic-demo-status" class="optimistic-demo__status" role="status"></p>
</div>
/*
* Presentation-only styles for the optimistic demo. The library toggles the
* hidden attribute on the show/hide faces and flips data-optimistic/aria-busy
* on the form; this CSS lays the two forms out and dims the form while a
* submission is in flight.
*/
.optimistic-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 32rem;
}
.optimistic-demo__hint {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.optimistic-demo__row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* While the optimistic state is applied (request in flight), dim the form. */
.optimistic-demo__form[data-optimistic="true"] {
opacity: 0.7;
}
.optimistic-demo__button {
display: inline-flex;
align-items: center;
}
.optimistic-demo__face {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.optimistic-demo__face .demo-icon {
width: 1rem;
height: 1rem;
}
/* The committed "Liked" face reads in the accent color. */
.optimistic-demo__face[data-stimeo--optimistic-target="show"] {
color: var(--accent);
}
.optimistic-demo__status {
margin: 0;
min-height: 1.25rem;
font-size: 0.9rem;
color: var(--muted);
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--optimistic"
Targets
| Name | Description | Attribute |
|---|---|---|
show
|
Revealed (hidden removed) while the submission is optimistic; kept on success, restored on failure. Optional, multiple allowed. |
data-stimeo--optimistic-target="show" |
hide
|
Hidden while the submission is optimistic; kept on success, restored on failure. Optional, multiple allowed. | data-stimeo--optimistic-target="hide" |
Events
| Name | Description | Event |
|---|---|---|
commit
|
Fires on submission success — the optimistic state is confirmed (the server response owns the final DOM). | stimeo--optimistic:commit |
rollback
|
Fires on submission failure — the optimistic changes have been restored precisely. | stimeo--optimistic:rollback |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-optimistic |
Form (controller element) | "true" while the optimistic state is applied (submission in flight); removed on success and failure alike. |
aria-busy |
Form (controller element) | "true" during submission, telling AT the region is busy (WCAG 4.1.2). |
data-optimistic-toggled |
Toggled show/hide targets | Marks the targets whose hidden attribute THIS controller flipped — the exact ownership scope of a rollback. |