楽観的 UI
stimeo--optimistic
即時適用・成功なら維持・失敗なら正確にロールバックする、Turbo フォームの楽観的 UI。
stimeo--optimistic は Turbo フォームをラップし、送信開始の瞬間に宣言された楽観状態を適用します。show ターゲットを表示、hide ターゲットを非表示にし、フォームに data-optimistic="true" と aria-busy="true" を付けます。turbo:submit-end が成功なら切替はそのまま維持して commit を発火 — 最終 DOM はサーバの Turbo Stream 応答(またはリダイレクト)が所有します。失敗ならこのコントローラが行った変更だけを正確に復元して rollback を発火します。実際に切り替えたターゲットにだけ data-optimistic-toggled マーカーを付けるため、作者が最初から表示していた要素を誤って隠しません。Turbo 自身の送信ライフサイクルイベントに委譲リスナ 2 本で乗るだけ — Action Cable も fetch のパッチも不要で、だからこそ stimeo-ui/cable ではなくゼロ依存のコアに置かれています。submit-once(二重送信ガード)や live-counter(楽観的数値)と自然に併用できます。show/hide 以外の宣言(クラスやテキストの差し替え)は commit / rollback イベントを購読する消費側の領分で、ロールバック時のエラー表示はフォームのエラー UI の責務です。
どちらのボタンもクリックした瞬間に「いいね済み」へ反転します(実体はボタンごとの Turbo フォーム送信です)。1 つ目のボタンは送信が成功するため反転が維持され(サーバの Turbo Stream がステータス行を更新します)、2 つ目のボタンは fail=1 を送ってサーバが 422 で拒否するため、反転がロールバックされる様子を観察できます。
<%# 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);
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--optimistic"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
show
|
送信中の楽観状態で表示(hidden を除去)。成功なら維持、失敗なら復元。任意・複数可。 |
data-stimeo--optimistic-target="show" |
hide
|
送信中の楽観状態で非表示。成功なら維持、失敗なら復元。任意・複数可。 | data-stimeo--optimistic-target="hide" |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
commit
|
送信成功で発火 — 楽観状態が確定(最終 DOM はサーバ応答が所有)。 | stimeo--optimistic:commit |
rollback
|
送信失敗で発火 — 楽観状態の変更は正確に復元済み。 | stimeo--optimistic:rollback |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-optimistic |
フォーム(コントローラ要素) | 楽観状態が適用されている間(送信中)"true"。成功・失敗のどちらでも除去。 |
aria-busy |
フォーム(コントローラ要素) | 送信中 "true"。AT に処理中を伝える(WCAG 4.1.2)。 |
data-optimistic-toggled |
切り替えた show / hide ターゲット | このコントローラが hidden を切り替えたターゲットのマーカー — ロールバックの正確な所有範囲。 |