Optimistic
stimeo--optimistic
Shows the result the moment you press, and undoes exactly what failed.
On a Turbo form, shows the outcome the moment you press. If the submission succeeds it stays, and the final page belongs to your server's response. If it fails, only what this changed is put back, so an element that was already visible is never hidden as collateral. That a submission is in flight is announced as well. It rides the signals Turbo already emits, so nothing about your requests is replaced and no connection is held open. Blocking double submits and sharing a live count between visitors are separate parts.
Both buttons flip to Liked the moment you press them. The first submission succeeds, so the flip stays. The second is rejected on purpose, so you can watch the flip roll back.
Keyboard
This component has no keyboard interactions of its own.
<%# 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-700);
}
.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 when the submission that owns the optimistic state succeeds — the state is confirmed (the server response owns the final DOM). Another submission's terminal is ignored. | stimeo--optimistic:commit |
rollback
|
Fires when the submission that owns the optimistic state fails — the authored markup has been put back exactly. A submission Turbo aborted counts as a failure. | 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). On the form itself Turbo writes the same attribute around each submission, so an authored value is lost there regardless of this controller. |
data-optimistic-toggled |
Toggled show/hide targets | Carries the authored hidden value to put back — "absent", or "value:" followed by the authored value. Only targets THIS controller wrote get one. |
data-optimistic-busy |
Form (controller element) | The same record for aria-busy, so a rollback puts back what the author wrote. |