はじめに
FBC Press: Hotwire ― HTML over the wire という設計思想から理解する
本書は、プログラミングスクール FjordBootCamp(フィヨルドブートキャンプ) の教材として作成された、Hotwire の教科書です。Rails の基本を学習済みの読者に向けています。
Hotwire は、ブラウザで動く JavaScript アプリケーションを大きく作る代わりに、サーバーで生成した HTML を活かして、現代的な操作感を実現するための考え方とツール群です。
本書では、思想や生まれた背景を扱いながら、Tailwind CSS 本よりもハンズオンを多くします。題材は、チーム向けタスク管理アプリ Relayです。この 1 つのアプリを最初から最後まで育てながら、通常の CRUD から Turbo Drive、Turbo Frames、Turbo Streams、Stimulus、Hotwire Native へ段階的に広げていきます。
想定読者
- Rails の MVC、CRUD、REST、フォームを学習済みである
- ERB と partial を使ったことがある
- JavaScript の基礎文法を読める
- SPA フレームワークを本格的に使った経験は必須ではない
読み方
第1部では、Hotwire の思想と背景を説明します。第2部以降は、サンプルアプリを育てながら手を動かします。
各部末のハンズオンは、その部で学んだ概念を実際の Rails アプリに適用するための章です。読み飛ばさずに進めると、最後に実務的な Hotwire アプリの形が残る構成にします。
バージョン基準
本書は 2026年6月時点の Hotwire 公式ドキュメントを基準にします。Turbo、Stimulus、Hotwire Native、Rails のバージョンによって挙動が変わる箇所は、章ごとに明記します。
第1部 Hotwire を理解する
この部では、まだ本格的なコードを書きません。Hotwire が何を解決するために生まれたのか、HTML over the wire という考え方がなぜ Rails と相性がよいのかを確認します。
第1章 Hotwire とは何か
この章のねらい
本書は、Hotwire を学ぶ本です。最初の章では、Hotwire を「Rails の便利機能の寄せ集め」ではなく、一つの設計思想として捉えます。
Hotwire の全体像、それを構成する Turbo・Stimulus・Hotwire Native の役割、JSON API + SPA との違い、そして Rails に標準で入っている意味を見ます。コードはまだ書きません。考え方の地図を手に入れるのが、この章のねらいです。
1.1 Hotwire の全体像
Hotwire という名前は、「HTML over the wire」という考え方に由来します。その名のとおり、サーバーが HTML を生成して送り、ブラウザがそれを賢く反映する、という考え方です(wire =通信路を、HTML が流れるイメージです)。
近年の Web アプリケーションの多くは、サーバーが JSON を返し、ブラウザ側の JavaScript が画面を組み立てる、という作りでした。Hotwire は、そこに一石を投じます。「画面は HTML なのだから、サーバーが HTML を送ればよいではないか」。この素朴な発想を、現代的な使い勝手で実現するのが Hotwire です。
ページ全体を毎回作り直すのではなく、必要な部分の HTML だけを送り、滑らかに差し替える。JavaScript は、必要なところに少しだけ足す。こうして、SPA のような体験を、サーバー中心のまま実現します。
1.2 Turbo / Stimulus / Hotwire Native の役割
Hotwire は、いくつかの道具の集まりです。役割が分かれています。
- Turbo … HTML の送受信と画面更新の中心です。ページ遷移を速くする Turbo Drive、ページの一部を独立して扱う Turbo Frames、部分更新の命令を送る Turbo Streams からなります。本書の第3部〜第5部で扱います。
- Stimulus … HTML に、少しの JavaScript で振る舞いを足す道具です。サーバー往復の要らない操作(開閉、入力補助など)を担います。第6部で扱います。
- Hotwire Native … Web の画面を、そのまま iOS / Android のモバイルアプリへ広げる道具です。第9部で扱います。
Turbo が屋台骨、Stimulus が補助、Hotwire Native がモバイルへの拡張、という関係です。どれも「HTML over the wire」という一つの考え方の現れです。
1.3 JSON API + SPA との違い
Hotwire の位置づけは、JSON API + SPA と比べると、はっきりします。
SPA(Single Page Application)では、サーバーは JSON を返し、ブラウザの JavaScript が、その JSON から画面を組み立てます。画面の状態は、主にブラウザ側に持ちます。リッチな操作ができる一方、JavaScript の量が増え、状態の管理が複雑になりがちです。
Hotwire では、サーバーが HTML を返します。画面を組み立てるのは、主にサーバーです。状態も、基本はサーバー(と HTML)が持ちます。JavaScript は最小限で済みます。
どちらが優れている、という話ではありません。向き不向きがあります(第10部で詳しく比較します)。多くの業務アプリのように、サーバーが持つデータを見せ、操作するのが中心なら、Hotwire は素直で保守しやすい選択肢になります。
1.4 Rails に標準で入っている意味
Hotwire は、Rails の既定です。rails new で新しいアプリを作ると、最初から Turbo と Stimulus が入っています。
これは、ただの同梱以上の意味を持ちます。Rails はもともと、サーバーで HTML を返すフレームワークです(第3章で詳しく見ます)。その Rails が、画面更新の標準的なやり方として Hotwire を選びました。つまり、Hotwire は Rails の思想と地続きであり、Rails を使うなら最初に手が届く選択肢だ、ということです。
本書が Hotwire を扱うのも、この理由からです。新しいライブラリを追加して身につけるのではなく、すでにそこにあるものを理解する。それが、Rails 開発者にとっての Hotwire です。
1.5 本書で作るサンプルアプリの見取り図
本書では、説明だけでなく、手を動かして学びます。題材は、チーム向けタスク管理アプリ Relayです。
Relay は、プロジェクトの下にタスクがぶら下がり、タスクにステータス・担当者・タグ・コメントが付く、という構成です。最初は、ごく普通の Rails の CRUD アプリとして作ります。そこから、検索、インライン編集、リアルタイム更新、モーダル、通知といった機能を、Hotwire で段階的に足していきます。
一つのアプリを最初から最後まで育てるので、「この機能を、なぜこの道具で作るのか」を、文脈の中で理解できます。Relay の詳しい仕様は、第2部(第4章)で共有します。
第1章では、Hotwire を「HTML over the wire」という設計思想として捉えました。次の第2章では、なぜ今、HTML を送る設計が見直されているのか、その背景を歴史から見ます。
参考資料
- Hotwire: https://hotwired.dev/
- Turbo: https://turbo.hotwired.dev/
- Stimulus: https://stimulus.hotwired.dev/
- Hotwire Native: https://native.hotwired.dev/
第2章 HTML over the wire という考え方
この章のねらい
第1章で、Hotwire は「サーバーが HTML を送る」設計だと見ました。しかし、なぜ今さら HTML なのでしょうか。Web は、JSON を送る方向へ進んできたはずです。
この章では、Web アプリケーションの画面更新の歴史をたどり、HTML を送る設計が、なぜ現代的な選択肢として見直されているのかを理解します。
2.1 Web アプリケーションの画面更新の歴史
最初期の Web は、単純でした。リンクをクリックすると、サーバーが新しいページの HTML を返し、ブラウザがそれを表示する。フォームを送信すると、また新しい HTML が返る。画面更新は、つねに「ページ全体の再読み込み」でした。
この作りは、分かりやすいものでした。サーバーは HTML を返すだけ、ブラウザは表示するだけ。状態はサーバーが持ちます。しかし、操作のたびにページ全体が白く再描画され、もっさりとした体験でした。
2.2 Ajax と JSON API の普及
そこで登場したのが Ajax です。ページ全体を再読み込みせず、JavaScript がバックグラウンドでサーバーと通信し、画面の一部だけを更新する。これにより、ページの一部を、滑らかに書き換えられるようになりました。
通信でやり取りするデータは、次第に JSON が主流になりました。サーバーは JSON でデータを返し、ブラウザの JavaScript が、その JSON を使って画面を組み立てる。サーバーは「データの供給源」、画面作りは「ブラウザの仕事」、という分業が進みました。
2.3 SPA が解決したこと、増やしたこと
この分業を、突き詰めたのが SPA(Single Page Application)です。最初に一度だけページを読み込み、あとはすべて JavaScript が、JSON をもとに画面を描き替えます。ページ遷移すら、JavaScript が担います。
SPA は、多くを解決しました。ネイティブアプリのような滑らかな操作感、複雑なクライアント側の表現。これらは、SPA の大きな成果です。
一方で、増やしたものもあります。複雑さです。画面を組み立てる責任が、サーバーからブラウザへ移ったことで、JavaScript の量が増えました。状態をブラウザ側に持つので、その管理が要ります。サーバーとクライアントで、データの形(JSON)の取り決めも要ります。同じような検証を、サーバーとクライアントの両方に書くことも起きます。多くのアプリにとって、この複雑さは、得られる体験に見合わないことがありました。
2.4 HTML を送ることの再評価
ここで、振り出しに戻る発想が出てきます。「画面は HTML なのだから、サーバーが HTML を送ればよいのではないか」。
ただし、最初期のように「ページ全体を再読み込み」するのではありません。Ajax の良いところ(部分更新)は活かしつつ、やり取りするのを JSON ではなく HTML にする。サーバーが、更新したい部分の HTML を送り、ブラウザがそれを差し替える。これが「HTML over the wire」です。
この発想には、利点があります。画面を組み立てるのは、引き続きサーバーです。だから、JavaScript は最小限で済みます。状態も、基本はサーバーが持ちます。検証も、サーバーに一本化できます。SPA が増やした複雑さの多くを、避けられます。Hotwire は、この「HTML を送る部分更新」を、現代的な使い勝手で実現する道具です。
2.5 Hotwire が向くアプリケーション
もちろん、HTML over the wire が、すべてのアプリに最適なわけではありません。向き不向きがあります。
向くのは、サーバーが持つデータを見せ、操作するのが中心のアプリです。一覧、詳細、作成、編集が主な操作で、画面の状態の多くがサーバー側にある。多くの業務アプリや管理画面、コンテンツ中心のサイトが、これに当てはまります。本書で作る Relay も、その典型です。
逆に、ブラウザ側で重い状態を扱うアプリ(描画ツール、表計算など)や、オフラインで動く必要があるアプリは、SPA の方が向きます。この使い分けは、第10部で改めて詳しく扱います。
第2章では、HTML を送る設計が見直されている背景を、歴史からたどりました。次の第3章では、この考え方が、なぜ Rails ととりわけ相性がよいのかを見ます。
参考資料
- Turbo: https://turbo.hotwired.dev/
- Hotwire: https://hotwired.dev/
第3章 なぜ Rails と Hotwire は相性がよいのか
この章のねらい
第2章で、HTML over the wire という考え方を見ました。この考え方は、Rails ととりわけ相性がよいものです。それは偶然ではありません。Hotwire は、Rails の作者たちが生み出したものだからです。
この章では、Rails のどの仕組みが Hotwire とどうつながるのかを整理します。第1部の締めとして、第2部以降の地図にします。
3.1 Rails はもともと HTML を返すフレームワークである
Rails は、登場したときから、サーバーで HTML を組み立てて返すフレームワークでした。controller がデータを用意し、view(ERB など)が HTML を描く。この MVC の流れは、Rails の核です。
第2章で見た「HTML over the wire」は、まさにこの Rails の基本そのものです。Hotwire は、Rails に新しい思想を持ち込むのではなく、Rails がもともと得意なこと(HTML を返す)を、現代的な部分更新へ自然に延長するものです。だから、両者は無理なく噛み合います。
3.2 partial と Turbo の相性
Rails には、view の一部を切り出して再利用する partial があります。_task.html.erb のように、画面の部品を partial にまとめておく書き方です。
この partial が、Turbo と抜群に相性がよいのです。Turbo Frames や Turbo Streams で部分更新するとき、更新する HTML は、たいてい partial で作ります。一覧の最初の表示でも、部分更新でも、同じ partial を使えます。Rails 開発者がふだん書いている partial が、そのまま Hotwire の部品になります(本書では第12章以降で実際に使います)。
3.3 RESTful controller と Turbo Streams
Rails は、REST に沿った controller を勧めます。index・show・new・create・edit・update・destroy という、おなじみの 7 つのアクションです。
Hotwire は、この RESTful な作りの上に、素直に乗ります。たとえば、create で作成したら一覧に追加する、destroy で削除したら一覧から消す、といった部分更新は、Turbo Streams で表現できます。普通の RESTful controller を、少し拡張するだけで、画面遷移のない更新になります(第5部で扱います)。新しい設計を覚え直すのではなく、いつもの controller の延長です。
3.4 Action Cable とリアルタイム更新
Rails には、WebSocket を扱う Action Cable があります。サーバーからブラウザへ、リアルタイムにデータを押し出す仕組みです。
Hotwire は、これと組み合わさります。あるユーザーの操作をきっかけに、サーバーが Turbo Streams の命令を Action Cable で配信すると、それを購読している全員の画面が、リアルタイムに更新されます。リアルタイム機能を、特別な JavaScript をほとんど書かずに作れます(第18章で扱います)。Rails の既存の仕組みが、そのまま活きます。
3.5 Hotwire を使うときの Rails 設計の変化
Hotwire を使っても、Rails の基本は変わりません。MVC、REST、partial、Action Cable。これまでの知識が、そのまま土台になります。
変わるのは、設計の意識です。
- view を、部分更新しやすいように partial へ分けることを、より意識します。
- controller のレスポンスを、HTML だけでなく Turbo Streams でも返せるように考えます。
- 要素の
idを、dom_idで一貫させ、部分更新の宛先にします。
どれも、Rails の作法から大きく外れるものではありません。「いつもの Rails を、部分更新に向く形に少し整える」。それが、Hotwire を使うときの設計の勘所です。本書は、この勘所を、Relay を育てながら身につけていきます。
第1部はここまでです。Hotwire を設計思想として捉え、その背景と、Rails との相性を見ました。次の第2部では、いよいよ手を動かします。本書を通して育てるサンプルアプリ Relay の仕様を決め、その土台を作ります。
参考資料
- Rails ガイド: https://guides.rubyonrails.org/
- Rails ガイド「レイアウトとレンダリング」: https://guides.rubyonrails.org/layouts_and_rendering.html
- Rails ガイド「Action Cable の概要」: https://guides.rubyonrails.org/action_cable_overview.html
- Hotwire: https://hotwired.dev/
演習(第1部): Hotwire の考え方を確かめる
この部ではまだコードを書きません。Hotwire の考え方を、観察と言語化で確かめます。
この部の到達状態
- Hotwire が向くアプリケーションと、SPA が向くアプリケーションを自分の言葉で比較できる
- ページ遷移時に「サーバーが HTML を返している」ことを、ブラウザの DevTools で観察できる
- 本書で育てる Relay の全体像をイメージできる
演習
- 既存の Rails アプリや公式デモで、リンク遷移時のネットワーク通信を DevTools で観察する
- 同じ操作を JSON API + SPA で作る場合との違いを書き出す
- Relay のどの画面で Hotwire が効きそうかを予想する
Relay の現在地
まだアプリは作っていません。 次の第2部で、Relay の土台(通常の CRUD)を作ります。
第2部 ハンズオンの準備
本書を通して育てる Rails サンプルアプリを用意します。通常の CRUD を作ってから、Hotwire で段階的に画面を改善していきます。
第4章 サンプルアプリの仕様
この章のねらい
本書では、1 つの Rails アプリケーションを最初から最後まで育てながら Hotwire を学びます。題材は、チーム向けタスク管理アプリ Relayです。
この章では、Relay の題材を選んだ理由、何を作って何を作らないか、モデルと画面の構成、そして各部でどこを Hotwire 化していくかを共有します。コードはまだ書きません。先に全体像を持っておくことで、以降の章で「いま何のためにこの機能を使っているのか」を見失わずに済みます。
4.1 題材の選定
Hotwire を学ぶ題材には、次の条件がそろっていると都合がよいです。
- 一覧画面があり、検索や絞り込みが自然に登場する
- データ件数が多く、ページネーションの必要が出てくる
- レコードに状態(ステータス)があり、バリデーションが意味を持つ
- 複数のユーザーが同じ画面を見て、リアルタイム更新が活きる
ToDo アプリでは薄すぎ、SNS のような題材では発散しすぎます。その中間にあるのがタスク管理です。プロジェクトの下にタスクがぶら下がり、タスクにステータス・担当者・タグ・コメントが付く、という構造は、上の条件をすべて自然に満たします。
そこで本書では、チーム向けタスク管理アプリ Relay を育てます。
4.2 題材に必要な条件
4.1 で挙げた条件を、Relay のどの要素が満たすかを対応させておきます。
| 必要な条件 | Relay での実現 |
|---|---|
| 一覧+検索 | タスク一覧をキーワード・ステータス・タグで絞り込む |
| 件数の多さ | seed で約 150 件のタスクを投入し、ページネーションを学ぶ |
| ステータス遷移 | タスクの status(todo / in_progress / done)を切り替える |
| 複数ユーザー | 同じタスクを複数人が開き、コメントや更新をリアルタイムに共有する |
この 4 点が、第7部「実務で使う Hotwire UI パターン」の題材にそのままつながります。
4.3 本書でやること / やらないこと
実務アプリを丸ごと作ろうとすると、Hotwire と関係のない作り込みが増え、学習の焦点がぼやけます。本書では、Hotwire を理解するうえで意味のある範囲に絞ります。何を捨てたかをはっきりさせておきます。
| 要素 | 本編での扱い | 理由 |
|---|---|---|
| 認証 | 入れる(最小) | current_user が担当者、コメント投稿者、broadcast 範囲に必要になるため。Rails 標準の認証機能を使う。 |
| 認可 | 本編は最小、深掘りは第31章 | 単一チーム前提にして、Pundit などの認可ライブラリは持ち込まない。 |
| マルチテナント / メンバー管理 | 入れない | Membership を入れると認可が重くなるため。1 チーム全員が同じプロジェクトを見られる前提にする。 |
| タグ | 入れる(第23章で UI 化) | 検索と絞り込みに必要。初期 CRUD ではなく、必要になった章で導入する。 |
| 担当者 | 入れる | 実務的なフィルタ、表示、broadcast の文脈に必要。Task から User への nullable 参照に留める。 |
| コメント | 入れる(UI は第16章以降) | Turbo Streams とリアルタイム更新の主役。モデルは第5章で用意し、UI は後で育てる。 |
| 通知 | 永続化しない | 本編では Action Cable による即時通知に限定する。永続 Notification モデルは付録候補にする。 |
このうち「入れるが UI は後で作る」もの(タグ・コメント)があるのは意図的です。最初からすべての画面を作り込むと、Hotwire の各機能を「なぜ使うのか」が見えにくくなります。必要になった章で初めて UI を足すことで、機能を使う動機が明確になります。
4.4 モデル構成
Relay のモデルは次の 6 つです。関連は素直で、Rails の学習者がつまずかない範囲に収めています。
| モデル | 主な属性 | 役割 |
|---|---|---|
User | name, email_address, password_digest | ログインユーザー、担当者、コメント投稿者 |
Project | name, description | タスクをまとめる単位 |
Task | project_id, title, description, status, assignee_id, due_on | 本書の中心リソース |
Comment | task_id, user_id, body | Turbo Streams と broadcast の題材 |
Tag | name | 検索・絞り込みの題材 |
Tagging | task_id, tag_id | Task と Tag の中間モデル |
関連をまとめると次のとおりです。
Projectは複数のTaskを持ちます。Taskは複数のCommentを持ちます。TaskはTaggingを介して複数のTagと多対多です。Taskは担当者としてUserを 1 人持ちます(未割り当ても許します)。Commentは投稿者としてUserを 1 人持ちます。
Task#status は todo、in_progress、done の enum にします。ステータスは検索、絞り込み、バリデーション、表示の出し分け、通知のすべてに使えるため、本書のハンズオンを支える軸になります。
なお User は Rails 標準の認証ジェネレータ(bin/rails generate authentication)で作ります。このジェネレータは User と Session を中心に、認証に必要な一式(コントローラ・ビュー・ルーティング・マイグレーション・bcrypt の導入)をまとめて生成します。これらは認証インフラなので、上表の Relay のドメインモデル(Project や Task など)とは区別して扱います。
具体的には、現在のユーザーを保持する
Current、ログインを扱うSessionsController、パスワード再設定のPasswordsController、認証ヘルパーをまとめた concern、再設定メール用のPasswordsMailerなども生成されます。
また、生成直後の User は email_address と password_digest を持ちますが name は含まないため、本書では生成後に name を追加します。詳しい手順は第5章で扱います。
4.5 主要画面
Relay の主要な画面と、そこで主に使う Hotwire の技術を対応させると次のようになります。どの画面でどの技術を使うかは、第3部以降で 1 つずつ実装していきます。
| 画面 | 役割 | 主に使う技術 |
|---|---|---|
| プロジェクト一覧 / 詳細 | 入口、通常のページ遷移の題材 | Turbo Drive |
| タスク一覧(リスト / ボード) | 検索、絞り込み、ページネーション | Turbo Frames + Stimulus |
| タスク詳細(サイドバー) | 遅延読み込み、タブ | Turbo Frames |
| タスク作成 / 編集 | バリデーション UX、モーダル | Turbo Frames + Turbo Streams + Stimulus |
| コメント欄 | 追記、削除、リアルタイム更新 | Turbo Streams + Action Cable |
| 通知トースト / flash | 操作結果、他者の更新 | Turbo Streams + Stimulus + Action Cable |
ここで大切なのは、1 つの画面が複数の技術の組み合わせでできていることです。Hotwire は「どれか 1 つを選ぶ」ものではなく、画面の要件に応じて使い分けるものだと、この表が示しています。
4.6 Hotwire 化するポイント
Relay は、まず通常の Rails の CRUD として作ります(第5章)。その時点では、画面遷移のたびにページ全体が再描画される、ごく普通の Rails アプリです。
そこから、次のような順序で段階的に Hotwire 化していきます。
- ページ遷移とフォーム送信を Turbo Drive で速く見せる(第3部)
- タスクの一覧・詳細・編集を Turbo Frames で部分更新する(第4部)
- 作成・更新・削除・コメントを Turbo Streams で画面遷移なしに反映し、リアルタイム更新まで広げる(第5部)
- サーバーとのやり取りが要らない振る舞いを Stimulus で足す(第6部)
- 検索・ページネーション・モーダル・通知など、実務的な UI に仕上げる(第7部)
重要なのは、いきなり完成形を作らないことです。通常の Rails アプリを出発点にして、「ここはページ全体を再描画しなくてよいはずだ」と気づいた箇所を、必要な技術で置き換えていきます。この進め方そのものが、実務で Hotwire を導入するときの考え方になります。
4.7 各部との機能対応表
本書のどの部で、Relay のどこを作る・変えるかを一覧にします。読んでいる途中で「いま全体のどこにいるのか」を見失ったら、この表に戻ってください。
| 部 | Relay で作る / 変える対象 | 主な技術 |
|---|---|---|
| 第3部 Turbo Drive | プロジェクト / タスクのページ遷移とフォーム送信 | Turbo Drive |
| 第4部 Turbo Frames | タスクのインライン編集、サイドバー詳細の遅延読み込み | Turbo Frames |
| 第5部 Turbo Streams | コメント追記・削除、タスクの作成・更新・削除、件数更新、Cable 配信 | Turbo Streams + Action Cable |
| 第6部 Stimulus | タスクフォームの補助、ドロップダウン、文字数、確認 UI | Stimulus |
| 第7部 UI パターン | タスクの検索・絞り込み、ページネーション、フォーム UX、モーダル / タブ / ドロップダウン、通知トースト | 総合 |
| 第8部 保守 | System Test、デバッグ、N+1、認可とセキュリティ | 横断 |
| 第9部 Native | Relay をネイティブシェルで包む | Hotwire Native |
| 第10部 選定 | Relay を題材にアンチパターンと SPA 比較を振り返る | 採用判断 |
第7部の章ごとの内訳(どの章で検索を作り、どの章でモーダルを作るか)は、第7部の各章で詳しく示します。
4.8 完成形の操作ストーリー
完成した Relay を、ユーザーの操作の流れで見てみます。これがゴールのイメージです。
- ログインすると、プロジェクト一覧が表示されます。リンクをたどってもページ全体は再読み込みされず、画面が一瞬で切り替わります(Turbo Drive)。
- プロジェクトを開くと、タスクの一覧が出ます。上部の検索ボックスにキーワードを入れると、一覧だけがその場で絞り込まれ、URL にも条件が反映されます。共有してもリロードしても同じ結果が出ます(Turbo Frames + Stimulus)。
- 一覧をスクロールすると、続きのタスクが追加で読み込まれます(ページネーション)。
- タスクの行で「編集」を押すと、その行だけがフォームに変わります。入力が不正なら、ページ遷移せずにエラーがその場に表示されます(Turbo Frames + バリデーション UX)。
- 「新規作成」を押すとモーダルが開きます。保存すると、モーダルが閉じ、一覧の先頭に新しいタスクが追加され、件数も更新されます(Turbo Streams)。
- タスクを開いてコメントを書くと、同じタスクを見ている他のメンバーの画面にも、リアルタイムでコメントが追加されます(Action Cable)。
- 操作のたびに、画面の隅にトーストが出て、少し経つと自動で消えます(Turbo Streams + Stimulus)。
この一連の操作は、すべてサーバーが HTML を返すことで実現します。JSON を組み立ててクライアントで描画する、という作りにはなっていません。これが HTML over the wire の体験です。
4.9 本書で採用する JavaScript 構成
Rails で JavaScript を扱う方法はいくつかありますが、本書では importmap を基本にします。
importmap は Rails の標準構成で、ビルド工程(npm や bundler 相当の JavaScript ビルド)を必要としません。学習者の環境差を最小化でき、Hotwire の本質に集中できます。
一方で、外部の npm パッケージを本格的に使う場合は jsbundling 構成が向く場面もあります。本書では第22章「外部ライブラリと連携する」で、importmap での外部ライブラリの読み込み方を扱います。構成そのものの詳しい比較は第6章で行います。
この章で決めたモデル名・画面名・ステータス値は、以降のすべての章で参照します。手を動かす前に、
Task#statusがtodo/in_progress/doneであること、タグとコメントの UI は後の章で作ることだけは覚えておいてください。
参考資料
- Hotwire: https://hotwired.dev/
- Rails Guides: https://guides.rubyonrails.org/
- Rails ガイド「Active Record の関連付け」: https://guides.rubyonrails.org/association_basics.html
第5章 Rails アプリを作成する
この章のねらい
第4章で決めた Relay の仕様を、実際の Rails アプリとして作ります。この章のゴールは、Hotwire のカスタマイズを一切していない「素の Rails アプリ」を、通常の CRUD が動く状態まで用意することです。
ここで作る状態が、第3部以降の出発点になります。あえて最初から Hotwire 化しないのは、「普通の Rails アプリのどこを、なぜ Hotwire で置き換えるのか」を後の章で実感するためです。
この章では、特に次の 3 点を固定します。
- Rails 8 標準の認証ジェネレータの実行結果
Project/Taskの CRUD と、Comment/Tag/Taggingをモデルだけに留める線引き- importmap を前提にした JavaScript 構成
本書のコードは Rails 8.0 以上で動作確認しています。コマンドや生成物が異なる場合は、お使いの Rails のバージョンを確認してください。
5.1 Rails アプリの作成
アプリを作成します。
rails new relay
cd relay
Rails 8 では、rails new の既定構成に本書で必要なものがそろっています。
- データベースは SQLite(学習用としてはこのままで十分です)
- JavaScript は import maps(ビルド工程が不要。Rails の既定です)
- Hotwire(
turbo-railsとstimulus-rails)がGemfileに最初から入っている
これらは生成されたアプリの実物で確認できます。Gemfile に turbo-rails と stimulus-rails が含まれ、config/importmap.rb と app/javascript/application.js が生成されています。中身の読み解きは第6章で行いますが、ここでは「追加のオプションを付けなくても、Hotwire を学ぶ準備が整っている」とだけ押さえれば十分です。
スタイルについては、本書は特定の CSS フレームワークに依存しません。見た目は最小限に留め、Hotwire の挙動に集中します。
サーバーを起動して、初期画面が出ることを確認します。
bin/rails server
ブラウザで http://localhost:3000 を開き、Rails の初期ページが表示されれば成功です。
5.2 認証の追加
Relay では、担当者・コメント投稿者・リアルタイム更新の配信範囲を決めるために current_user が必要です。そこで、Rails 8 標準の認証ジェネレータを使います。
bin/rails generate authentication
bin/rails db:migrate
このジェネレータは、User と Session を中心に、認証に必要な一式(コントローラ・ビュー・ルーティング・マイグレーション・bcrypt の導入)をまとめて生成します。これで、認証(ログイン・ログアウト)とパスワード再設定の土台が入ります。ただし、パスワード再設定メールを実際に届けるには、別途メール送信(Action Mailer)の設定が必要です。本書ではメール配信そのものは扱わず、ログインを使います。
ここで 1 つ、初級者がつまずきやすい点があります。このジェネレータが作るのはログイン機能であって、サインアップ(ユーザー登録)画面ではありません。本書では、ユーザーは後述の seed データで作成し、その認証情報でログインします。
次に、第4章で決めたとおり User に name を追加します。生成直後の User は email_address と password_digest を持ちますが、name は持たないためです。
bin/rails generate migration AddNameToUsers name:string
bin/rails db:migrate
5.3 モデルの作成
第4章のモデル構成に沿って、Relay のモデルを作ります。ここでポイントになるのが、CRUD 画面まで作るモデルと、モデルだけ用意するモデルを分けることです。その線引きは 5.4 と 5.5 で扱います。
まず、各モデルの関連を確認しておきます。生成後に、次の関連を各モデルへ書き加えます。
app/models/project.rb
class Project < ApplicationRecord
has_many :tasks, dependent: :destroy
end
app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
belongs_to :assignee, class_name: "User", optional: true
has_many :comments, dependent: :destroy
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
enum :status, { todo: 0, in_progress: 1, done: 2 }, default: :todo
validates :title, presence: true
end
status は第4章で決めたとおり enum にします。todo / in_progress / done の 3 状態が、後の検索・絞り込み・バリデーション・通知すべての軸になります。担当者(assignee)は未割り当てを許すため optional: true にします。
User にも、担当しているタスクとコメントの関連を加えます。認証ジェネレータが生成した行はそのまま残し、クラスの中に次の 2 行を追記します。
app/models/user.rb
class User < ApplicationRecord
# 認証ジェネレータが生成した内容(has_secure_password など)はそのまま残します
# ↓ 次の 2 行を追記します
has_many :assigned_tasks, class_name: "Task", foreign_key: :assignee_id, dependent: :nullify
has_many :comments, dependent: :destroy
end
5.4 通常 CRUD の生成(Project と Task)
Project と Task は、ユーザーが画面から操作するリソースです。CRUD 画面ごと作るため、scaffold を使います。
bin/rails generate scaffold Project name:string description:text
bin/rails generate scaffold Task project:references title:string description:text status:integer due_on:date assignee:references
ここで、2 つの参照の扱いが違う点に注意します。project は「タスクは必ずどれかのプロジェクトに属する」ため、必須の参照のままで構いません。一方 assignee は「担当者は未割り当てもありうる」うえに、assignees テーブルではなく User を指す必要があります。
そこで assignee の参照だけを直します。生成されたマイグレーションは、そのままでは assignees テーブルを参照しようとし、かつ必須になります。User を参照し、未割り当てを許すように直します。
db/migrate/xxxxxx_create_tasks.rb(該当行を修正)
t.references :assignee, null: true, foreign_key: { to_table: :users }
マイグレーションを実行します。
bin/rails db:migrate
これで、Project と Task の一覧・詳細・作成・編集・削除が動くようになります。scaffold が生成したビューは、Rails 8 では最初から Turbo に対応した形になっています。この事実は 5.10 で確認します。
5.5 Comment / Tag / Tagging はモデルだけ用意する
Comment・Tag・Tagging は、第4章で「入れるが UI は後の章で作る」と決めたものです。ここでは scaffold ではなく model ジェネレータを使い、モデルとマイグレーションだけを作ります。コントローラもビューも作りません。
bin/rails generate model Comment task:references user:references body:text
bin/rails generate model Tag name:string
bin/rails generate model Tagging task:references tag:references
bin/rails db:migrate
関連を書き加えます。
app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :task
belongs_to :user
validates :body, presence: true
end
app/models/tag.rb と app/models/tagging.rb
class Tag < ApplicationRecord
has_many :taggings, dependent: :destroy
has_many :tasks, through: :taggings
end
class Tagging < ApplicationRecord
belongs_to :task
belongs_to :tag
end
ここで UI を作り込まないのは、意図的な線引きです。コメントは第16章(Turbo Streams)で、タグは第23章(検索と絞り込み)で、それぞれ「必要になったから足す」形で UI を作ります。そうすることで、各機能を使う動機が明確になります。
5.6 seed データ
検索やページネーションを後の章で体感するには、ある程度の件数が必要です。seed データを用意します。ログイン用のユーザーも、ここで作ります。
seed は、何度実行しても同じ結果になるように書きます。先頭でドメインデータを消してから作り直し、ログイン用のユーザーは find_or_create_by! で重複を避けます。こうしておくと、bin/rails db:seed を繰り返しても失敗しません。
db/seeds.rb
# 再実行できるように、ドメインデータを子から順に消してから作り直します
[Tagging, Comment, Task, Tag, Project].each(&:delete_all)
user = User.find_or_create_by!(email_address: "alice@example.com") do |u|
u.name = "Alice"
u.password = "password"
end
tags = %w[bug feature chore urgent].map { |name| Tag.create!(name: name) }
3.times do |i|
project = Project.create!(name: "プロジェクト #{i + 1}", description: "サンプルプロジェクトです。")
50.times do |n|
task = project.tasks.create!(
title: "タスク #{i + 1}-#{n + 1}",
description: "サンプルのタスクです。",
status: Task.statuses.keys.sample,
assignee: [user, nil].sample,
due_on: Date.current + rand(0..30)
)
task.tags << tags.sample(rand(0..2))
task.comments.create!(user: user, body: "最初のコメントです。") if n.even?
end
end
投入します。
bin/rails db:seed
これで、プロジェクト 3 件・タスク約 150 件・数種のタグ・散在するコメントが入ります。ページネーション(第24章)や検索(第23章)を体感できる件数です。
5.7 System Test の準備
Hotwire の動きは、画面を実際に操作して確かめるのが確実です。Rails の System Test を使えるようにしておきます。
Rails 8 では rails new の時点で System Test の足場(Capybara とヘッドレスブラウザの設定)が用意されています。最初のテストとして、プロジェクト一覧が表示されることを確認する小さなテストを書きます。
test/system/projects_test.rb
require "application_system_test_case"
class ProjectsTest < ApplicationSystemTestCase
test "プロジェクト一覧が表示される" do
visit projects_path
assert_selector "h1", text: "Projects"
end
end
実行します。
bin/rails test:system
5.8 最初の動作確認
ここまでの状態を、ブラウザで通して確認します。
bin/rails serverでサーバーを起動するhttp://localhost:3000/session/newを開き、alice@example.com/passwordでログインするhttp://localhost:3000/projectsでプロジェクト一覧を見る- プロジェクトを作成・編集・削除する
- タスクを作成し、無効な入力(タイトル空)でバリデーションエラーが出ることを確認する
この時点では、まだ Hotwire のカスタマイズを何もしていません。それでも、画面遷移やフォーム送信は動きます。
5.9 各部のハンズオンで使うテスト方針
本書では、機能を Hotwire 化するたびに、その章で小さな System Test を 1 〜 2 本書きます。たとえば、インライン編集(第12章)、Turbo Streams での作成・削除(第16章)、検索(第23章)などです。
これらの小さなテストは、第8部「Hotwire アプリを保守する」で、テスト戦略として束ね直します。この章では「各章でテストを書く」という方針だけ共有しておきます。
5.10 通常 CRUD の時点で Turbo Drive が効いていることを確認する
最後に、重要な事実を確認します。ここまでで作った素の CRUD は、すでに Turbo Drive 経由で動いています。
Rails 8 では、rails new の時点で turbo-rails が読み込まれます。app/javascript/application.js を見ると、次のように Turbo が読み込まれているはずです。
app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"
ブラウザの DevTools を開き、Network タブを見ながらプロジェクト一覧のリンクをたどってみてください。ページ全体が再読み込みされず、必要な部分だけが差し替わっていることがわかります。これが Turbo Drive の働きです。
このことは、第3部の理解にとって大切です。第3部「Turbo Drive」は、新しい機能を足す章ではなく、すでに動いている仕組みを理解し、契約どおりに整える章です。その出発点が、いまできあがりました。
この章で作った Relay は、これ以降のすべての章の土台です。モデル名・
statusの値・「コメントとタグの UI は後で作る」という線引きは、後の章で繰り返し前提になります。
参考資料
- Rails Guides: https://guides.rubyonrails.org/
- Rails ガイド「テスティング」: https://guides.rubyonrails.org/testing.html
- Rails ガイド「Active Record の関連付け」: https://guides.rubyonrails.org/association_basics.html
- Rails セキュリティガイド(認証): https://guides.rubyonrails.org/security.html
第6章 Hotwire の標準構成を確認する
この章のねらい
第5章で作った Relay には、すでに Hotwire 一式が入っています。この章では、それらが「どのファイルにあり、どう読み込まれているか」を把握します。
仕組みを先に押さえておくと、第3部以降で「なぜこの 1 行で Turbo が動くのか」「Stimulus のコントローラはなぜ自動で登録されるのか」と迷わずに済みます。あわせて、本書が JavaScript 構成として importmap を主軸にする理由と、その限界も確認します。
6.1 turbo-rails
Turbo は turbo-rails という gem で入っています。この gem は、2 つのものをまとめて提供します。
- JavaScript パッケージ
@hotwired/turbo-rails。これを import すると、Turbo の JavaScript(Turbo Drive / Frames / Streams)が効きます。 - サーバー側の Rails 用ヘルパー(
turbo_frame_tagやturbo_streamなど)と、stream/broadcast の Rails 統合。broadcast 系の API は第18章で扱います。
ブラウザ側は、第5章でも見たとおり app/javascript/application.js から読み込まれます。
app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"
この import "@hotwired/turbo-rails" の 1 行で、Turbo Drive がページ全体に効くようになります。Relay のリンク遷移がフルリロードにならないのは、この行のおかげです。
サーバー側のヘルパーは、第4部以降の view で使います。たとえば turbo_frame_tag(第11章)や turbo_stream(第15章)は、すべて turbo-rails が提供しています。
6.2 stimulus-rails
Stimulus の本体は stimulus-rails という gem で入っています。Stimulus のコントローラは app/javascript/controllers/ に置きます。
app/javascript/application.js の import "controllers" が、このディレクトリのコントローラをまとめて読み込んでいます。読み込みの実体は次の 2 ファイルです。
app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eagerLoadControllersFrom が、controllers/ 配下のコントローラを自動で見つけて登録します。だから、新しいコントローラを作るときは、ファイルを所定の場所に置くだけで動きます。手動での登録は要りません。この仕組みは第6部(Stimulus)で実際に使います。
6.3 本書の基本構成: importmap
Relay の JavaScript は importmap で読み込まれています。どのライブラリをどの名前で読み込むかは、config/importmap.rb に書かれています。
config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin は「この名前で import したら、このファイルを使う」という対応づけです。application.js に書いた import "@hotwired/turbo-rails" が、ここの pin を通じて実体に結びつきます。pin_all_from は、6.2 で見たコントローラ群をまとめて読み込めるようにする指定です。
importmap の状態は、次のコマンドで確認できます。
bin/importmap json
6.4 なぜ本書では importmap を主軸にするのか
importmap の最大の特徴は、JavaScript のビルド工程が要らないことです。ES モジュールをブラウザにそのまま配信し、import の名前解決を import map で行います。
本書が importmap を主軸にする理由は次のとおりです。
- Rails 8 の既定構成なので、追加のセットアップが要らない
- npm や bundler 相当のビルドツールを学ぶ必要がなく、Hotwire の本質に集中できる
- 読者の環境差(Node のバージョンなど)の影響を受けにくい
本書は「Rails 学習済みの初級者が Hotwire を理解する」ことが目的です。ビルド構成の習得に寄り道しないために、importmap を選びます。
6.5 jsbundling 構成が必要になる場面
一方で、importmap が万能というわけではありません。次のような場合は、jsbundling(esbuild などでビルドする構成)が向きます。
- 多数の npm パッケージや、依存関係の複雑なライブラリを使う
- TypeScript や JSX など、変換(トランスパイル)が必要なコードを書く
- ツリーシェイキングでバンドルサイズを最適化したい
つまり、JavaScript を本格的に書くアプリでは jsbundling が選択肢になります。本書の範囲では importmap で困らないため採用しませんが、「規模が大きくなったら乗り換える選択肢がある」ことは知っておいてください。
6.6 外部ライブラリを importmap で扱う方針
Relay でも、第22章で日付ピッカーやチャートといった外部ライブラリを使います。importmap でも、外部ライブラリは扱えます。importmap は、import する名前と取得先を対応づける仕組みなので、公開された ES モジュール(ESM)を pin して使います。
bin/importmap pin <ライブラリ名>
ただし注意点があります。importmap が面倒を見るのは JavaScript だけです。ライブラリが必要とする CSS は、別途読み込む必要があります。また、ES モジュールとして配信されていないライブラリは、そのままでは扱いにくい場合があります。
外部ライブラリの初期化と破棄(Turbo の画面差し替えと両立させる方法)は、第22章で具体的に扱います。この章では「importmap でも外部ライブラリを使える。ただし CSS は別」という方針だけ押さえます。
6.7 開発中に見るべきログとブラウザ DevTools
Hotwire の挙動を理解・デバッグするには、サーバーのログとブラウザの DevTools を併せて見ます。第8部(デバッグ)の土台になるので、見る場所を先に確認しておきます。
サーバーログ(bin/rails server の出力)では、リクエストの形式がわかります。たとえば Turbo Streams のリクエストは、ログに Processing as TURBO_STREAM と出ます。これが出ているかどうかで、Turbo Streams として処理されたかを確認できます。
ブラウザの DevTools では、次の 3 つを見ます。
- Network タブ: リクエストの形式とレスポンス。Turbo Streams のレスポンスは MIME タイプが
text/vnd.turbo-stream.htmlになります。 - Console タブ: 次の 2 つは別物として見ます。(1) Stimulus の debug ログ。
app/javascript/controllers/application.jsのapplication.debugをtrueにすると、コントローラの接続状況がログに出ます。(2) Turbo イベントの自前ログ。Turbo のイベント(visit や submit など)は自動では出ないので、自分でaddEventListenerを仕込んで出します(仕込み方は第10章・第29章で扱います)。 - Elements タブ:
<turbo-frame>やdata-controller属性が、HTML のどこに付いているか。
これらの見方は、各機能の章で繰り返し使います。いまは「困ったらこの 3 か所を見る」と覚えておけば十分です。
第2部はここまでです。Relay の土台ができました。ところで、まだ何も手を加えていないこの素の CRUD でも、リンクをたどるとページ全体は再読み込みされません。なぜでしょうか。その答えが Turbo Drive です。第3部では、すでに動いているこの仕組みを、正面から読み解いていきます。
参考資料
- turbo-rails: https://github.com/hotwired/turbo-rails
- stimulus-rails: https://github.com/hotwired/stimulus-rails
- Rails ガイド「Rails で JavaScript を扱う」: https://guides.rubyonrails.org/working_with_javascript_in_rails.html
ハンズオン(第2部): サンプルアプリの土台を作る
このハンズオンでは、本書を通して育てるタスク管理アプリ Relay の土台を作ります。ここではまだ Hotwire のカスタマイズはしません。通常の Rails CRUD が動き、以降の章で段階的に Hotwire 化できる状態を目指します。
各部のハンズオンは「到達状態 → 作る・変える → 完成条件 → つまずきやすい点 → Relay の現在地」の順で進めます。
この部の到達状態
このハンズオンを終えると、次の状態になります。
- Rails 8.0 以上で Relay が起動する
- ログイン・ログアウトができる(Rails 標準の認証機能)
ProjectとTaskを一覧・詳細・作成・編集・削除できるComment、Tag、Taggingはモデルと migration だけ用意してある(UI は後の部で作る)- seed データが入っていて、一覧に十分な件数が並ぶ
- System Test を書く準備ができている
作る・変える
順番に進めます。各手順は「変更前 → 変更後 → 動作確認」で確認します。
- アプリを作成する
- Rails 8.0 以上で新規アプリを作成します(
importmapを使う標準構成)。
- Rails 8.0 以上で新規アプリを作成します(
- 認証を追加する
- Rails 標準の認証ジェネレータでログイン機能を入れます。
- 生成された
Userはemail_addressとpassword_digestだけを持つので、nameを追加します。 - 認証用の
Sessionモデルも生成されますが、これは認証インフラなので Relay のドメインモデルとは区別します。
- モデルを作成する
Project/Task/Comment/Tag/Taggingを用意します(属性は第4章のモデル構成表のとおり)。Task#statusはtodo/in_progress/doneの enum にします。
- 通常 CRUD を生成する
ProjectとTaskの一覧・詳細・作成・編集・削除を作ります。Comment/Tag/Taggingの UI はここでは作りません(コメントは第16章以降、タグは第23章以降)。
- seed データを入れる
- プロジェクト 3 件、タスク約 150 件、タグ数種、コメントを散らして投入します。
- ページネーションや検索を後の部で体感できるだけの件数にします。
- System Test の準備をする
- System Test が動く環境を整え、最初の 1 本を書けるようにします。
完成条件
- ログインして
Taskを作成・編集・削除できる Taskの一覧に seed のタスクが多数並ぶ- 無効な入力で作成しようとするとバリデーションエラーが表示される
Comment/Tag/Taggingのテーブルが存在する(UI はまだない)
つまずきやすい点
- 認証ジェネレータの
Userにはnameが無いので、追加を忘れると一覧やコメントで表示できません。 Task#statusの enum 値と seed の値がずれると、後の絞り込み(第23章)で破綻します。- seed の件数が少なすぎると、ページネーション(第24章)や検索(第23章)の体感が薄くなります。
Relay の現在地
素の CRUD が Turbo Drive で動く状態。Hotwire のカスタマイズはまだ何もしていない。 次の第3部で、この土台に対して Turbo Drive の挙動を契約どおりに整えます。
なお Rails 7 以降は
turbo-railsが標準で入るため、この時点で既にページ遷移は Turbo Drive 経由になっています。これは第5章 5.10 / 第7章で確認します。
第3部 Turbo Drive
Turbo Drive を使って、リンク遷移とフォーム送信を高速に見せる仕組みを学びます。
第7章 Turbo Drive の基本
この章のねらい
第5章で作った Relay は、まだ Hotwire のカスタマイズを何もしていません。それでも、リンクをたどると画面が一瞬で切り替わります。これは Turbo Drive が働いているからです。
この章では、その Turbo Drive の心臓部を理解します。鍵になるのは visit(訪問)という考え方です。第3部のあいだ、この言葉を何度も使います。
この部を貫く軸は「すべては visit である」です。Turbo Drive は、リンクもフォーム送信も visit という同じ処理に揃えます。visit とは「HTML を取得し、
<body>を差し替え、<head>をマージする」ことです。
7.1 通常のページ遷移
まず、Turbo がなかった頃のページ遷移を思い出します。
従来の Rails では、リンクをクリックするたびに、ブラウザは次のことをしていました。
- サーバーに新しいページを要求する
- 返ってきた HTML で、ページ全体を捨てて作り直す
- CSS と JavaScript をすべて読み込み直す
この作り直しには、いくつかの問題があります。毎回ページ全体を再構築するため、一瞬白い画面が見えます。スクロール位置は失われます。そして、JavaScript で保持していた状態(開いていたメニューなど)も、すべてリセットされます。
ページの大部分(ヘッダー、サイドバー、CSS、JavaScript)は前のページと同じなのに、毎回まるごと作り直しているわけです。ここに無駄があります。
7.2 Turbo Drive の visit と body 差し替え
Turbo Drive は、この無駄をなくします。リンクのクリックを横取りし、ページ全体の作り直しではなく、必要な部分だけの差し替えに変えます。
Turbo Drive がリンク遷移で行うことを、本書では visit と呼びます。visit の中身は次のとおりです。
- リンク先の HTML を、バックグラウンドで取得する(
fetch) - 取得した HTML の
<body>で、いまの<body>を差し替える <head>は、まるごと捨てずにマージする(7.3 で扱います)
ページ全体を作り直さないので、白い画面が出ません。<head> で読み込み済みの CSS や JavaScript も、読み込み直されません。だから、JavaScript の実行環境を毎回ゼロから立て直さずに済みます。これが、Relay のリンク遷移が速い理由です。
なお、visit には 2 種類あります。リンクのクリックなど新しいページへ進むときの visit(application visit)と、ブラウザの戻る・進むで起きる visit(restoration visit)です。restoration visit はスクロール位置やキャッシュと関わります。この章では主に application visit を扱い、restoration visit とキャッシュは第9章で扱います。
なお、速くなるのは「読み込み済みの資産を読み直さない」からであって、<body> の中身は差し替わります。開いていたメニューのような body 内の UI 状態は、特別な指定をしない限りリセットされます(状態を保つ方法は第9章で扱います)。
大切なのは、サーバー側は何も特別なことをしていない点です。Relay の controller は、通常どおり HTML を返しているだけです。その HTML をどう適用するか(全体を作り直すか、body だけ差し替えるか)を、ブラウザ側の Turbo Drive が引き受けています。
7.3 head のマージと data-turbo-track="reload"
visit では <body> を差し替えますが、<head> は扱いが異なります。<head> には CSS や JavaScript の読み込み指定が入っています。これを毎回読み込み直すと、Turbo Drive の利点が消えてしまいます。
そこで Turbo Drive は、<head> を捨てずにマージします。新しいページで増えた要素は取り込み、すでに読み込み済みの CSS や JavaScript はそのまま使い続けます。
ただし、困る場面があります。アプリをデプロイして CSS や JavaScript の中身が変わったときです。古い CSS のまま body だけ差し替え続けると、見た目が壊れます。
これを防ぐのが data-turbo-track="reload" です。Rails の既定レイアウトでは、CSS の読み込みにこの指定が付いています。
app/views/layouts/application.html.erb
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
ここで追跡対象になるのは、data-turbo-track="reload" を付けた要素です。上の例では、stylesheet_link_tag に自分でこの属性を指定した CSS がそれにあたります。Turbo Drive は、visit のたびに追跡対象の新旧を比べ、内容が変わっていたら(=デプロイで CSS が更新されていたら)、body だけの差し替えをやめて、ページ全体を読み込み直します。
2 行目の javascript_importmap_tags は、import map を使って JavaScript を読み込むためのタグをまとめて生成するヘルパーです(第6章)。追跡したい資産には、このように data-turbo-track="reload" を付けて監視対象にする、と理解しておけば十分です。
つまり、普段は visit で速く、アセットが変わったときだけ安全にフルリロードする、という使い分けが自動で行われます。
7.4 progress bar
visit では、リンク先の HTML をバックグラウンドで取得します。回線が遅いと、取得に時間がかかることがあります。その間、画面は前のページのままなので、ユーザーには「クリックが効いたのか」がわかりません。
Turbo Drive は、これに備えて progress bar(進捗バー)を用意しています。visit に時間がかかると、画面上部に細いバーが表示されます。
ここで 1 つ知っておくべき仕様があります。progress bar は、visit を始めてすぐには出ません。既定では 500 ミリ秒経ってから表示されます。多くの遷移は 500 ミリ秒以内に終わるため、その場合はバーが出ません。一瞬で終わる遷移にまでバーを出すと、かえってちらついて見えるからです。
この遅延時間は変更できますが、まずは「すぐに終わる遷移ではバーは出ない」という既定の動きを理解しておけば十分です。
7.5 visit を無効化する
Turbo Drive は、すべてのリンクとフォームに自動で効きます。ほとんどの場面ではそれで問題ありませんが、ときには visit させたくないリンクもあります。たとえば、別のサーバーが返すファイルのダウンロードや、Turbo と相性の悪い外部ページへの遷移です。
そうしたリンクには、data-turbo="false" を付けます。
<%= link_to "PDF をダウンロード", report_path(format: :pdf), data: { turbo: false } %>
data-turbo="false" が付いたリンクは、Turbo Drive が横取りせず、従来どおりのページ遷移になります。
この指定は、囲んだ範囲にもまとめて効かせられます。あるコンテナ全体で visit を切り、その中の一部だけ visit を戻したい場合は、内側で data-turbo="true" を指定します。
無効化はあくまで例外的な手段です。Turbo Drive を切るほど、Hotwire を使う意味は薄れます。「ここは visit させない方がよい」とはっきり言える場面に限って使ってください。
第7章では、Turbo Drive のリンク遷移を visit として理解しました。次の第8章では、フォーム送信も同じ visit であること、そして成功と失敗で controller が返すべきものが決まっていることを見ます。
参考資料
- Turbo Drive(Handbook): https://turbo.hotwired.dev/handbook/drive
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
第8章 リンクとフォーム送信の仕組み
この章のねらい
第7章では、リンクのクリックが visit になることを見ました。この章では、フォーム送信も同じ visit になることを理解します。
そして、フォーム送信では controller が返す内容に約束ごとがあります。成功したときと失敗したときで、返すべきものが決まっているのです。この約束ごとは、第5部(Turbo Streams)と第7部(フォーム UX)の土台になります。第3部の中でもっとも大切な章です。
8.1 GET リンクと visit
第7章で見たとおり、リンクのクリックは visit になります。リンクは HTTP の GET リクエストです。Turbo Drive は GET の遷移を横取りし、body を差し替えます。
GET は、フォームでも使えます。たとえば検索フォームは GET です。検索ボックスに入力して送信すると、/tasks?q=... のような URL へ GET し、その結果で body が差し替わります。これも visit です(検索は第23章で実装します)。
GET は「サーバーの状態を変えない読み取り」です。次に見る、状態を変える送信とは扱いが分かれます。
8.2 POST / PATCH / DELETE の送信
タスクの作成・更新・削除は、サーバーの状態を変えます。これらは GET ではなく、POST(作成)・PATCH(更新)・DELETE(削除)で送ります。
従来の Rails では、フォームを送信するとページ全体が再読み込みされていました。Turbo Drive では、フォーム送信も visit として扱われます。Turbo がフォーム送信を横取りし、バックグラウンドで送信し、返ってきた結果を画面に反映します。
ここで問題になるのが、「送信のあと、何を返すか」です。GET のリンクなら、行き先の HTML を返せば済みます。しかし、状態を変える送信では、成功する場合と失敗する場合があります。この 2 つで、返すべきものが変わります。
8.3 成功時は redirect
送信が成功したとき、controller は リダイレクトを返します。たとえばタスクを作成できたら、作成したタスクの詳細ページへリダイレクトします。
Relay の Task は scaffold で作ったので、create はすでにこの形になっています。
app/controllers/tasks_controller.rb(create の一部)
if @task.save
redirect_to @task, notice: "Task was successfully created."
else
render :new, status: :unprocessable_entity
end
成功時に redirect_to を返すと、Turbo はそのリダイレクト先へ visit します。結果として、作成したタスクの詳細ページへ画面が切り替わります。
更新(update)と削除(destroy)のリダイレクトには、もう 1 つ指定が付きます。
app/controllers/tasks_controller.rb(update と destroy の一部)
# update
redirect_to @task, notice: "Task was successfully updated.", status: :see_other
# destroy
redirect_to tasks_path, notice: "Task was successfully destroyed.", status: :see_other
status: :see_other は HTTP の 303 です。これは「この場所を GET で見に行ってください」という意味のリダイレクトです。
なぜ更新と削除にだけ付けるのでしょうか。PATCH や DELETE で送信したあと、Turbo はリダイレクトを追いかけます。このとき 303 を返すと、リダイレクト先を確実に GET で取得します。これがないと、リダイレクト先を元のメソッド(PATCH や DELETE)で取得しようとして、意図しない動きになることがあります。だから Rails の scaffold は、update と destroy のリダイレクトに status: :see_other を付けています。
8.4 失敗時は 422 でフォームを再描画する
送信が失敗したとき、たとえばタスクのタイトルが空でバリデーションに引っかかったときは、リダイレクトしません。入力中のフォームを、エラー付きで返します。
このとき大切なのが、HTTP ステータスです。8.3 のコードの失敗側を、もう一度見ます。
else
render :new, status: :unprocessable_entity
end
render :new でフォームを描き直し、status: :unprocessable_entity(HTTP の 422)を付けています。
Turbo は、422 で返ってきたフォームの HTML で、いまの body を差し替えます。その結果、ページ遷移せずに、入力した内容とエラーメッセージがその場に表示されます。ユーザーは、入力をやり直せます。
なお、戻し先は操作によって変わります。作成(create)の失敗なら new を、更新(update)の失敗なら edit を、それぞれ 422 で再描画します。「失敗したら、いま編集していたフォームを 422 で返す」という形は同じです。
本書では、このステータスをコードで書くときは
status: :unprocessable_entityを使います。HTTP のステータス名としては「422」と表記します。
8.5 status code は Turbo との契約
ここまでをまとめると、状態を変えるフォーム送信には、はっきりした約束ごとがあります。
- 成功したら、リダイレクトを返す(更新・削除は 303)
- 失敗したら、フォームを 422 で返す
この約束ごとを、本書では Turbo との契約と呼びます。Turbo は、フォーム送信の結果がリダイレクトなら visit し、422 ならその HTML で body を差し替える、という前提で動いているからです。
逆に、この契約を外すと正しく動きません。よくあるつまずきは、失敗時に 422 ではなく 200 でフォームを返してしまうことです。
状態を変えるフォーム送信に対して 200 で HTML を返しても、Turbo はその HTML を画面に反映せず、送信元の URL にとどまります。理由は 2 つあります。1 つは、もし送信先(フォームの action)の URL に切り替えてしまうと、その後リロードしたとき、その action URL に対して GET が飛んでしまうからです。その URL は GET では存在しないかもしれません。もう 1 つは、POST のリロードでブラウザが出す「フォームを再送信しますか?」という確認を、Turbo が再現できないからです。
だから Turbo は、成功時はリダイレクト(更新・削除は 303)、エラー時は 4xx/5xx(通常は 422)で返すことを前提にしています。エラーを 200 で返すと、画面に反映されず、ユーザーにエラーが伝わりません。成功も失敗も、redirect か 422 に揃える、と覚えてください。
この契約は、第25章「バリデーションエラーとフォーム UX」で本格的に使います。ここでは「成功は redirect、失敗は 422」という対応を覚えておいてください。
8.6 この章の System Test
契約どおりに動いているかを、System Test で確かめます。Relay のタスク作成について、成功と失敗の 2 つを書きます。
第5章ではタスクをフラットな scaffold で作ったので、作成画面は new_task_path です。タスクには所属プロジェクトが必要なので、テストの中で先に用意します。
test/system/tasks_test.rb
require "application_system_test_case"
class TasksTest < ApplicationSystemTestCase
setup do
@project = Project.create!(name: "テスト用プロジェクト")
end
test "タスクを作成すると遷移して成功メッセージが出る" do
visit new_task_path
fill_in "Title", with: "最初のタスク"
fill_in "Project", with: @project.id
click_on "Create Task"
assert_text "Task was successfully created"
end
test "タイトルが空だと同じ画面にエラーが出る" do
visit new_task_path
fill_in "Title", with: ""
fill_in "Project", with: @project.id
click_on "Create Task"
assert_text "prohibited this task from being saved"
end
end
1 つ目は成功のケースです。リダイレクトが起き、成功メッセージが表示されることを確認します。2 つ目は失敗のケースです。422 でフォームが差し替わり、エラー表示が出ることを確認します。ページ遷移していないことが、この契約の肝です。
フォームの項目名(
Title、Projectなど)や成功・エラーの文言は、scaffold が生成したビューに由来します。生成された画面に合わせて読み替えてください。Projectという項目名は、project_idのラベルを humanize したものです(project_id→Project)。所属プロジェクトを指定するため、ここではプロジェクトの ID を入力しています。
実行します。
bin/rails test:system
第8章では、フォーム送信も visit であり、成功は redirect、失敗は 422 という契約があることを見ました。次の第9章では、visit を速く見せるキャッシュと、Turbo 8 の morphing を扱います。
参考資料
- Turbo Drive(Handbook): https://turbo.hotwired.dev/handbook/drive
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
- Rails ガイド「レイアウトとレンダリング」: https://guides.rubyonrails.org/layouts_and_rendering.html
第9章 キャッシュ、プレビュー、リロード、morphing
この章のねらい
第7章で、visit には 2 種類あると触れました。リンクで進む application visit と、戻る・進むで起きる restoration visit です。この章では、restoration visit を支えるキャッシュと、それが引き起こす意図しない表示を扱います。
そのうえで、Turbo 8 から入ったmorphingを学びます。これは「差し替え方」そのものを変える仕組みで、第5部のリアルタイム更新(第18章)にもつながります。
この章は 2 つに分かれます。前半(キャッシュ系)は「差し替えを速く見せる」話、後半(morphing 系)は「差し替え方を変える」話です。
9.1 snapshot cache
Turbo Drive は、訪れたページをスナップショットとして覚えています。これを snapshot cache と呼びます。
ページから離れる直前に、Turbo はそのページの状態を 1 枚のスナップショットとして保存します。あとで戻る・進むの操作(restoration visit)をしたとき、保存しておいたスナップショットを即座に表示します。サーバーに取りに行かないので、戻る操作が一瞬で終わります。
従来のページ遷移では、戻るときも毎回サーバーへ取りに行くことがありました。Turbo は、一度見たページをスナップショットで持っておくことで、戻る操作を速くします。
9.2 preview 表示
snapshot cache は、戻る操作だけでなく、前に見たページへ進むときにも使われます。これが preview(プレビュー)です。
キャッシュに残っているページへ visit すると、Turbo はまずキャッシュ版を即座に表示します。これがプレビューです。同時に、裏で最新の内容をサーバーから取得し、届いたら本物に差し替えます。
つまり、ユーザーには「一瞬で表示された」ように見えますが、最初に見えているのは少し古いキャッシュかもしれません。プレビュー表示中は、<html> に data-turbo-preview という属性が付くので、DevTools で見分けられます。
この仕組みは速さを生む一方で、問題も生みます。たとえば、古い件数や古いフラッシュメッセージが、一瞬だけプレビューに表示されることがあります。これを防ぐ手段が、次に見る data-turbo-temporary と meta タグです。
9.3 data-turbo-temporary とキャッシュ制御
プレビューに古い内容を出したくない要素には、data-turbo-temporary を付けます。
たとえば、フラッシュメッセージのように「一度きりで、次に出てほしくない」要素に付けます。
<div data-turbo-temporary>
<%= notice %>
</div>
data-turbo-temporary が付いた要素は、ページがキャッシュに保存される前に、Turbo が自動で取り除きます。だから、プレビューにその要素が出ることはありません。フラッシュメッセージが、戻ったときに一瞬よみがえる、という不自然さを防げます。
ページ単位で止めたいときは、そのページの <head> に meta タグを置きます。値は用途で 2 つに分かれます。
<meta name="turbo-cache-control" content="no-cache">
no-cache は、そのページをキャッシュしません。戻る操作(restoration visit)でも、毎回サーバーへ取りに行きます。
<meta name="turbo-cache-control" content="no-preview">
no-preview は、戻る操作のためのキャッシュは残しつつ、プレビューとしての表示だけを止めます。「戻る操作は速いままにしたいが、古い内容をプレビューで見せたくない」場合に向きます。
9.4 data-turbo-track の再確認
第7章で見た data-turbo-track="reload" も、ここで関わります。これは「アセット(CSS など)が変わったら、body だけの差し替えをやめてフルリロードする」仕組みでした。
キャッシュ・プレビューと合わせて整理すると、Turbo Drive は次のように振る舞います。
- 普段は、visit で body だけを差し替える(速い)
- 前に見たページは、プレビューで即座に見せる(さらに速い)
- ただし、追跡対象のアセットが変わったときは、フルリロードする(安全)
速さと安全さを、状況に応じて自動で切り替えているわけです。
9.5 キャッシュと Stimulus の関係
キャッシュは、Stimulus(第6部で扱う JavaScript の仕組み)と関わります。ここでは関係だけ押さえます。
ページがキャッシュに保存される直前、Turbo は turbo:before-cache というイベントを発火します。また、ページが差し替わるとき、Stimulus のコントローラは一度切断(disconnect)され、新しいページで再接続(connect)されます。
ここで注意が要ります。スナップショットは「その瞬間の DOM」をそのまま保存します。もし JavaScript で一時的に書き換えた状態(開いたメニュー、初期化済みの外部ライブラリなど)が残っていると、それごとキャッシュされ、プレビューで再現されてしまいます。
そのため、turbo:before-cache のタイミングで一時的な状態を元に戻す、外部ライブラリは disconnect で後始末する、といった対応が必要になります。具体的な書き方は第22章(外部ライブラリとの連携)で扱います。
9.6 Turbo 8 の page refresh と morph
ここから後半です。視点が変わります。前半は「差し替えを速く見せる」話でした。後半は「差し替え方そのものを変える」話です。
Turbo 8 では、page refreshという考え方が加わりました。いまいる URL と同じ URL へ visit したとき、Turbo はそれを「ページの再描画」として扱えます。たとえば、フォーム送信のあと同じ一覧ページへ戻る場合などです。
既定では、page refresh もこれまでどおり body をまるごと差し替えます。これを morph(モーフィング)に切り替えると、差し替え方が変わります。
morph は、新しい HTML と現在の DOM を比べ、変わった部分だけを書き換えます。全体を捨てて作り直すのではなく、差分を当てる形です。これにより、入力中のフォーカスやスクロール位置、変化していない要素の状態が保たれます。
たとえば Relay のタスクボードで、1 件のステータスが変わったとき。body 全体の差し替えだと、見ていた位置やフォーカスが飛びます。morph なら、変わった 1 件だけが書き換わり、ほかはそのまま残ります。
9.7 turbo-refresh-method
morph を有効にするには、レイアウトの <head> に次の meta タグを置きます。
<meta name="turbo-refresh-method" content="morph">
content には morph か replace を指定します。既定は replace(全体の差し替え)です。morph にすると、page refresh のときに差分だけを当てる morph になります。
9.8 turbo-refresh-scroll
morph と一緒に使うのが、スクロール位置の扱いです。
<meta name="turbo-refresh-scroll" content="preserve">
content には preserve か reset を指定します。既定は reset(先頭に戻す)です。preserve にすると、page refresh の前後でスクロール位置が保たれます。長い一覧の途中で更新がかかっても、見ていた位置から動きません。
9.9 data-turbo-permanent と morph の関係
morph でも「絶対に触ってほしくない要素」があります。たとえば、再生中の動画プレーヤーや、開いたままにしておきたいメニューです。
そうした要素には、data-turbo-permanent を付け、id を与えます。
<div id="player" data-turbo-permanent>
<!-- 再生中のプレーヤーなど -->
</div>
data-turbo-permanent が付いた要素は、id で識別され、ページが変わっても保持されます。morph の対象からも外れるため、差し替えや差分適用で壊されません。
9.10 turbo-stream action="refresh" と broadcast refresh
page refresh は、Turbo Streams からも起こせます。第15章で扱う Turbo Streams の refresh アクションです。
<turbo-stream action="refresh" method="morph" scroll="preserve"></turbo-stream>
この stream を受け取ると、ブラウザは page refresh を行います。method="morph" と scroll="preserve" を付ければ、9.7・9.8 と同じく morph・スクロール保持で再描画できます。
これがリアルタイム更新で活きます。あるユーザーの操作をきっかけに、サーバーが「このページを refresh してください」という stream を全員へ配信すれば、各自の画面が morph で最新化されます。細かい差分を 1 つずつ broadcast しなくても、「最新の状態に揃える」ことができます。この broadcast refresh は、第18章(Action Cable でのリアルタイム更新)で扱います。
ここまでで、Turbo Drive を「visit」という 1 つの軸で読み解き、フォーム送信の契約、キャッシュ・プレビュー、morph まで見ました。次の第10章では、visit の前後に割り込むイベントの制御を扱い、第3部を締めます。
参考資料
- Turbo Drive(Handbook): https://turbo.hotwired.dev/handbook/drive
- Page Refreshes と morphing(Handbook): https://turbo.hotwired.dev/handbook/page_refreshes
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
第10章 Turbo Drive のイベントと制御
この章のねらい
ここまでで、Turbo Drive が visit でページを差し替える様子を見てきました。多くの場合、Turbo は自動で動くので、こちらから何かする必要はありません。
しかし、ときには visit の途中に割り込みたい場面があります。「削除の前に確認したい」「遷移に時間がかかるあいだローディングを出したい」「何が起きているかログで追いたい」といった場面です。
この章では、そのために使う Turbo のイベントと、いくつかの制御方法を見ます。これは第3部の締めであり、第8部(デバッグ)の入口にもなります。
10.1 visit ライフサイクルの主要イベント
visit は、一瞬で終わるように見えて、内部ではいくつかの段階を踏んでいます。Turbo は、その節目ごとにイベントを発火します。リンクのクリックから画面表示までの主な流れは、次のとおりです。
turbo:click— Turbo が有効なリンクをクリックしたturbo:before-visit— visit を始める直前turbo:visit— visit を始めたturbo:before-render— 新しい body を描画する直前turbo:render— 描画したturbo:load— ページの読み込みが完了した(初回と、各 visit のあと)
これらのイベントは、すべて document で発火します。必要な段階に合わせてイベントを購読すれば、その瞬間に処理を差し込めます。
ここで思い出してほしいのが、第6章で触れた点です。Turbo のイベントは、自分で購読しない限り、何も表に出てきません。次の節から、実際に購読して制御してみます。
10.2 submit 前後の制御
フォーム送信にも、専用のイベントがあります。送信の前後で処理を挟みたいときに使います。
turbo:submit-start— フォーム送信が始まったturbo:submit-end— フォーム送信が終わった(成功・失敗の結果を含む)
たとえば、送信中は送信ボタンを押せないようにして、二重送信を防ぎたいとします。turbo:submit-start でボタンを無効にし、turbo:submit-end で戻す、という形が考えられます。
なお、Turbo は送信中のボタンを自動で無効にする機能も持っています。二重送信の防止や、送信中の表示の作り込みは、第25章(フォーム UX)でまとめて扱います。ここでは「送信の前後にイベントがある」ことを押さえておけば十分です。
10.3 visit の制御
visit そのものを止めたい場面もあります。たとえば「入力中の内容が保存されていないので、ページを離れる前に確認したい」といった場合です。
turbo:before-visit は、visit を始める直前に発火します。このイベントで event.preventDefault() を呼ぶと、visit を中断できます。
逆に、こちらから visit を起こすこともできます。Turbo.visit() を使います。
Turbo.visit("/projects", { action: "replace" })
action: "replace" を付けると、履歴に新しく積まず、いまの履歴を置き換えて遷移します。プログラムから画面を切り替えたいときに使います。
10.4 確認ダイアログとローディング表示
実務でよく使う制御は、わざわざイベントを書かなくても、属性だけで実現できます。
削除の前に確認したいときは、data-turbo-confirm を使います。Relay のプロジェクト削除に付けてみます。
<%= button_to "削除", project_path(project),
method: :delete,
data: { turbo_confirm: "本当に削除しますか?" } %>
data-turbo-confirm が付いたリンクやボタンを操作すると、Turbo はまず確認ダイアログを出します。ユーザーが承認したときだけ、送信に進みます。既定ではブラウザの確認ダイアログ(window.confirm)が使われます。
ローディング表示については、第7章で見た progress bar が、遅い visit のときに自動で出ます。多くの場合はこれで十分です。これ以上の作り込み(特定の領域だけにスピナーを出すなど)は、10.2 のイベントを使って行います。
10.5 デバッグ用イベントログ
Turbo の動きがわからなくなったときは、イベントをログに出すのが有効です。第6章で触れたとおり、Turbo のイベントは自分で購読しないと表に出ません。逆に言えば、購読すれば何でも観察できます。
主なイベントをまとめてログに出す、小さなコードを置いてみます。
;["turbo:click", "turbo:before-visit", "turbo:visit", "turbo:before-render", "turbo:render", "turbo:load"].forEach((name) => {
document.addEventListener(name, (event) => {
console.log(name, event.detail)
})
})
これで、リンクをたどるたびに、どのイベントがどの順で発火しているかが Console に出ます。event.detail には、visit 先の URL などの情報が入っています。
このログは、第29章(デバッグとイベント観察)で本格的に使います。ここでは「困ったらイベントを観察できる」と知っておけば十分です。
10.6 turbo:morph 系イベントを観察する
第9章で見た morph にも、専用のイベントがあります。
turbo:before-morph-element— ある要素を morph する直前(各要素ごと)turbo:morph— morph が終わった
turbo:before-morph-element は要素ごとに発火し、event.preventDefault() でその要素の morph をスキップできます。外部ライブラリが管理している要素など、「ここは Turbo に触らせたくない」という部分を守るのに使えます。
morph が思ったとおりに当たらないときは、これらのイベントを観察すると、どの要素がどう書き換わっているかを追えます。
第3部はここまでです。Turbo Drive を「visit」という 1 つの軸で読み解きました。リンクとフォームの visit、成功と失敗の契約、キャッシュとプレビュー、morph、そしてイベントによる制御です。次の第4部では、ページの一部を独立した visit 領域として扱う Turbo Frames に進みます。
参考資料
- Turbo のイベントリファレンス: https://turbo.hotwired.dev/reference/events
- Turbo Drive(Handbook): https://turbo.hotwired.dev/handbook/drive
- Page Refreshes と morphing(Handbook): https://turbo.hotwired.dev/handbook/page_refreshes
ハンズオン(第3部): 通常の CRUD を Turbo Drive で動かす
第2部で作った素の CRUD に対して、Turbo Drive の挙動を契約どおりに整えます。
この部の到達状態
- リンク遷移が visit(body 差し替え+ head マージ)として高速に動く
- フォーム送信が「成功は redirect、失敗は 422 再描画」に統一されている
data-turbo-track="reload"で、アセット更新時だけフルリロードがかかる- progress bar が出る
- プロジェクト削除前に
data-turbo-confirmで確認が出る
作る・変える
- controller のステータスコードを契約どおりに整える(
update/destroyはstatus: :see_other、失敗は 422) - 確認ダイアログとローディング表示を足す
- キャッシュとプレビューの挙動を観察する
完成条件
- 無効な Task で 422 が返り、フォームがエラー付きで残る
- 作成成功で詳細へ遷移する
- これらを System Test で確認できる
Relay の現在地
遷移とフォームの土台が契約どおりに動く状態。 次の第4部で、ページの一部だけを更新する Turbo Frames に進みます。
第4部 Turbo Frames
Turbo Frames を使って、ページの一部を独立したコンテキストとして更新する方法を学びます。
第11章 Turbo Frames の基本
この章のねらい
第3部では、Turbo Drive がリンクやフォーム送信を visit として扱い、ページの <body> をまるごと差し替えることを見ました。
しかし実務では、「ページ全体ではなく、一部だけを差し替えたい」場面がよくあります。タスク一覧の 1 行だけを編集フォームに変えたい、詳細パネルだけを切り替えたい、といった場面です。
これを実現するのが Turbo Frames です。この章では、Turbo Frames の基本的な考え方と書き方を学びます。
この部を貫く軸は「frame は独立した小さな visit 領域である」です。第3部の visit が
<body>全体の差し替えなら、Turbo Frame は<turbo-frame>単位の差し替えです。frame 内のリンクやフォームは、ページ全体ではなく frame の中だけを差し替えます。
11.1 Turbo Frames とは
第3部で見た visit は、ページ全体が対象でした。リンクをクリックすると、<body> がまるごと差し替わります。
これは多くの場面で便利ですが、行きすぎることもあります。たとえば、タスク一覧で 1 件を編集したいだけなのに、ページ全体が切り替わると、ヘッダーもサイドバーも他の行も、すべて描画し直されます。本当は 1 行だけ変えたいのに、です。
Turbo Frames は、ページの一部を <turbo-frame> という枠で囲み、その枠を独立した visit 領域にします。枠の中のリンクやフォームは、ページ全体ではなく、その枠の中だけを差し替えます。枠の外(ヘッダーやサイドバー)は、まったく動きません。
第3部の visit を「<body> 全体の差し替え」とすれば、Turbo Frames は「<turbo-frame> 単位の差し替え」です。スコープが違うだけで、起きていることは同じ visit です。
11.2 turbo_frame_tag と id の一致ルール
frame は、turbo_frame_tag ヘルパーで作ります。
<%= turbo_frame_tag "task_detail" do %>
<p>ここが frame の中です。</p>
<% end %>
これは、次の HTML を生成します。
<turbo-frame id="task_detail">
<p>ここが frame の中です。</p>
</turbo-frame>
ここで、Turbo Frames でもっとも大切なルールを押さえます。frame は id で対応づけられる、というルールです。
frame の中のリンクをたどると、Turbo はリンク先の HTML を取得し、その中から同じ id を持つ <turbo-frame> を探して、中身を差し替えます。つまり、リンク元のページとリンク先のページの両方に、同じ id の frame がなければなりません。
id は手で書く以外に、モデルから作ることもできます。dom_id を使うと、レコードに対応した id になります。
<%= turbo_frame_tag dom_id(@task) do %>
<%= @task.title %>
<% end %>
これは id="task_1" のような frame を生成します(dom_id は第17章でも重要になります)。レコードごとに一意な id が要るとき、この書き方が役立ちます。
もし、リンク先に同じ id の frame がなかったらどうなるでしょうか。その場合、Turbo はこれをエラーとして扱います。frame には案内メッセージが書き込まれ、例外が投げられて Console にも表示されます。これは Turbo Frames でいちばん多いつまずきです。「frame に意図しない案内メッセージが出た」「Content missing と出る」ときは、まず両側の id が一致しているかを確認してください(よくあるエラーは付録Eにまとめます)。
11.3 frame 内リンク
frame の中にリンクを置くと、そのリンクは frame の中だけを差し替えます。
<%= turbo_frame_tag "task_detail" do %>
<%= link_to "次のタスク", task_path(@next_task) %>
<% end %>
このリンクをクリックすると、Turbo は task_path(@next_task) を取得し、その中の id="task_detail" の frame を探して、中身を差し替えます。ページの他の部分は動きません。
ここで 1 つ、第3部との違いがあります。frame 内のリンクで遷移しても、ブラウザの URL は変わりません。あくまで frame の中身が変わるだけだからです。これは便利な一方で、「URL と画面の見た目がずれる」という設計上の注意点にもなります。この点は第14章(Frames の失敗パターンと設計判断)で詳しく扱います。
11.4 frame 内フォーム
フォームも同じです。frame の中に置いたフォームは、送信しても frame の中だけを差し替えます。
ここで、第8章で学んだ契約がそのまま効きます。成功時はリダイレクト、失敗時は 422 でフォームを再描画、という契約です。frame の中では、その差し替えが frame のスコープで起こります。
たとえば、frame の中の編集フォームを送信したとします。
- 成功すると、Turbo はリダイレクト先を取得し、同じ
idの frame を探して差し替えます(表示に戻る) - 失敗すると、422 で返ってきたフォームが、同じ frame の中に差し替わります(エラー付きフォームがその場に出る)
ページ遷移せずに、frame の中だけで「編集 → 保存 → 表示」「編集 → エラー → 再入力」が完結します。この仕組みを使ったインライン編集は、第12章で実際に作ります。
11.5 data-turbo-frame で別の frame を target する
ここまでは、frame の中のリンクが「自分の frame」を差し替える例でした。しかし、「frame の中のリンクで、別の場所を差し替えたい」こともあります。
そのときは、リンクに data-turbo-frame を付けて、対象の frame の id を指定します。
<%= link_to "詳細を開く", task_path(@task), data: { turbo_frame: "task_detail" } %>
このリンクは、自分がどこにあっても、id="task_detail" の frame を差し替えます。一覧の行から、別の場所にある詳細パネルを更新する、といった使い方ができます。
特別な値として _top があります。これを指定すると、frame の中だけでなく、ページ全体を visit します。
<%= link_to "全体を更新", task_path(@task), data: { turbo_frame: "_top" } %>
frame の中のリンクは、既定では frame の中を差し替えます。「このリンクだけはページ全体を遷移させたい」というときに、_top を使います。frame に閉じ込められた画面から、通常のページ遷移へ抜け出す手段です。
第11章では、frame を「独立した小さな visit 領域」として理解し、
idの一致というルールを押さえました。次の第12章では、Relay の一覧・詳細・編集フォームを frame 化し、インライン編集を作ります。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo Frames リファレンス: https://turbo.hotwired.dev/reference/frames
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
第12章 一覧、詳細、編集フォームを Frame 化する
この章のねらい
第11章で、Turbo Frames の基本(id の一致、frame 内のリンクとフォーム)を学びました。この章では、それを Relay の実際の画面に当てはめます。
作るのはインライン編集です。タスク一覧の 1 行で「編集」を押すと、その行だけがフォームに変わり、保存すると表示に戻ります。失敗したら、その行の中にエラーが出ます。ページ遷移は一切起きません。
第11章で学んだ「frame 内フォームの 303 / 422 契約」が、ここで実際に動きます。
12.1 一覧行を frame にする
まず、タスク一覧の各行を frame にします。第5章の scaffold が生成した _task partial を、turbo_frame_tag で囲みます。
変更前(scaffold が生成したままの _task):
app/views/tasks/_task.html.erb
<div id="<%= dom_id task %>">
<p><%= task.title %></p>
<p><%= task.status %></p>
<%= link_to "編集", edit_task_path(task) %>
</div>
変更後(frame で囲む):
app/views/tasks/_task.html.erb
<%= turbo_frame_tag task do %>
<p><%= task.title %></p>
<p><%= task.status %></p>
<%= link_to "編集", edit_task_path(task) %>
<% end %>
turbo_frame_tag task は、dom_id(task) を使って id="task_1" のような frame を生成します(第11章で見た turbo_frame_tag dom_id(task) の短い書き方です)。
これで、一覧の各行がそれぞれ独立した frame になりました。一覧は <%= render @tasks %> でこの partial を繰り返し描画するので、行ごとに task_1、task_2… という frame が並びます。
12.2 詳細を frame にする
詳細画面(show)も、同じ partial を使うようにします。こうすると、一覧と詳細で「タスク 1 件の表示」を共通化できます。
app/views/tasks/show.html.erb
<%= render @task %>
render @task は、いま作った _task partial を描画します。つまり、詳細画面も id="task_1" の frame を含むことになります。
ここが大切です。一覧の行も、詳細画面も、同じ id の frame で「タスク 1 件」を表示している状態になりました。第11章のルールを思い出してください。frame は id で対応づけられます。表示も編集も、この id="task_1" という同じ土俵の上で差し替え合うことになります。
これは意図的な割り切りです。ここでは「タスク 1 件の表示」という単位を、一覧でも詳細でも同じ _task で再利用することを狙っています。そのため show.html.erb は、いまは render @task の 1 行だけにしています。詳細画面に固有の枠(見出しやサイドバーなど)が必要になったら、ページの外枠を show.html.erb に残し、その中にこの frame を置く形にもできます。実際に、第13章ではこの「外枠の中に frame を置く」構成を使って、サイドバーやタブを作ります。
12.3 インライン編集
いよいよ編集です。編集画面(edit)のフォームを、同じ id の frame で囲みます。
app/views/tasks/edit.html.erb
<%= turbo_frame_tag @task do %>
<%= render "form", task: @task %>
<% end %>
これで準備が整いました。動きを追ってみます。
- 一覧の行(frame
task_1)の中の「編集」リンクをクリックする - Turbo は
edit_task_pathを取得し、その中からid="task_1"の frame を探す edit.html.erbの frame(同じid="task_1")が見つかり、その中身(フォーム)で行を差し替える
結果として、その行だけが編集フォームに変わります。ほかの行も、ヘッダーも動きません。
保存するとどうなるでしょうか。ここで第8章の契約が効きます。
- 成功時:
updateはリダイレクトします(status: :see_other)。Turbo はリダイレクト先(詳細画面)を取得し、id="task_1"の frame=表示用の_taskを取り出して差し替えます。行が表示に戻ります。 - 失敗時:
updateは 422 でeditを再描画します。id="task_1"の frame=エラー付きフォームが、その行に差し替わります。
update アクションは、第5章の scaffold が生成したままで動きます。成功時に status: :see_other が付いていること(第8章)が、ここで効いています。
12.4 キャンセル導線
編集をやめて表示に戻す導線も要ります。編集フォームの中に、詳細へ戻るリンクを置きます。
app/views/tasks/edit.html.erb
<%= turbo_frame_tag @task do %>
<%= render "form", task: @task %>
<%= link_to "キャンセル", task_path(@task) %>
<% end %>
このキャンセルリンクは frame task_1 の中にあります。クリックすると、Turbo は詳細画面(task_path)を取得し、id="task_1" の frame=表示用の _task を取り出して差し替えます。フォームが表示に戻ります。
保存もキャンセルも、「id="task_1" の frame を、表示の _task に戻す」という同じ動きだとわかります。frame の id を揃えてあるおかげで、どの導線も同じ土俵で差し替え合えるのです。
12.5 partial 設計
ここまでで、partial の設計が自然に決まりました。
_task.html.erb… タスク 1 件の表示。frame で囲む。一覧と詳細の両方で使う_form.html.erb… タスクのフォーム。newとeditの両方で使うedit.html.erb…_formを frame で囲んだもの
ポイントは、表示(_task)を 1 か所にまとめたことです。一覧でも詳細でも、保存後の差し替えでも、同じ _task が使われます。もし行ごとに表示を別々に書いていたら、修正のたびにすべてを直す羽目になります。frame の id を軸に partial を共通化することが、Turbo Frames を使った設計の土台になります。
12.6 この章の System Test
インライン編集が動くことを、System Test で確認します。
test/system/tasks_test.rb(追記)
test "一覧でインライン編集できる" do
project = Project.create!(name: "テスト用プロジェクト")
task = project.tasks.create!(title: "編集前のタイトル")
visit tasks_path
within "##{dom_id(task)}" do
click_on "編集"
fill_in "Title", with: "編集後のタイトル"
click_on "Update Task"
end
within "##{dom_id(task)}" do
assert_text "編集後のタイトル"
assert_no_field "Title"
end
end
within "##{dom_id(task)}" で、対象タスクの frame の中だけに操作を絞っています。frame の中で編集リンクを押し、フォームを書き換え、保存します。
保存後の確認では、2 つを見ます。更新後のタイトルが表示されていること(assert_text)と、編集フォームの入力欄が消えていること(assert_no_field "Title")です。タイトルの文字列だけを確認すると、編集フォームが残ったまま(=保存に失敗して差し替わっていない)でも、入力した値でテストが通ってしまいます。「フォームが消えて表示に戻った」ことまで確かめることで、frame が表示用の _task に差し替わったことを保証できます。
ページ遷移していないこと、操作したのが 1 つの frame の中だけであること。これが、インライン編集が成立している証拠です。
第12章では、
idを揃えた partial を軸に、ページ遷移のないインライン編集を作りました。次の第13章では、srcを使った遅延読み込みと、サイドバー・タブといった画面分割のパターンを学びます。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo Frames リファレンス: https://turbo.hotwired.dev/reference/frames
- Rails ガイド「レイアウトとレンダリング」: https://guides.rubyonrails.org/layouts_and_rendering.html
第13章 遅延読み込みと独立したナビゲーション
この章のねらい
第12章では、frame の中身を「リンクやフォームの操作」で差し替えました。この章では、もう 1 つの差し替えのきっかけを学びます。src による遅延読み込み(lazy loading)です。
frame に src を与えると、その frame は自分で中身を取りに行きます。これを使うと、重い部分を後回しで読み込んだり、サイドバーやタブのように画面を分割したりできます。第12章の終わりで触れた「外枠の中に frame を置く」構成を、ここで実際に作ります。
13.1 lazy loading
frame に src 属性を与えると、frame はページに現れた時点で、その URL から中身を自動で取得します。読み込みのきっかけは src です。
例として、プロジェクトの詳細画面に「そのプロジェクトのタスク一覧」を遅延読み込みで表示してみます。タスク一覧は件数が多く、本体の表示を遅らせたくないからです。
まず、タスク一覧だけを返すアクションを足します。
config/routes.rb
resources :projects do
member do
get :tasks_panel
end
end
app/controllers/projects_controller.rb(追記)
def tasks_panel
@project = Project.find(params[:id])
end
app/views/projects/tasks_panel.html.erb
<%= turbo_frame_tag "project_tasks" do %>
<%= render @project.tasks %>
<% end %>
そして、プロジェクト詳細に frame を置き、src でこのアクションを指します。
app/views/projects/show.html.erb(抜粋)
<h1><%= @project.name %></h1>
<%= turbo_frame_tag "project_tasks", src: tasks_panel_project_path(@project), loading: :lazy do %>
<p>タスクを読み込んでいます…</p>
<% end %>
ここで、src と loading: :lazy の役割を分けて押さえます。src だけなら、frame はページに現れた時点ですぐに読み込みを始めます。loading: :lazy を足すと、読み込みのタイミングが「frame が画面に見える(スクロールで表示領域に入る)まで」遅れます。src が自動ロードの入口、loading: :lazy が遅延の条件です。
src 先のレスポンスには、第11章のルールどおり、同じ id="project_tasks" の frame が必要です。tasks_panel.html.erb がそれを満たしています。
プロジェクト詳細を開くと、まず本体(プロジェクト名)が即座に表示され、タスク一覧は少し遅れて frame の中に現れます。重い部分を本体の表示から切り離せました。
13.2 skeleton 表示
src 先の読み込みが終わるまで、frame には最初に書いておいた中身が表示されます。13.1 の例では「タスクを読み込んでいます…」がそれです。
この「読み込み中に見せておくもの」を、実際のレイアウトに似せた灰色の枠(skeleton、スケルトン)にすると、画面の見た目が安定します。読み込みの前後でガクッとレイアウトが変わらず、ユーザーの体感がよくなります。
<%= turbo_frame_tag "project_tasks", src: tasks_panel_project_path(@project), loading: :lazy do %>
<div class="skeleton">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
<% end %>
skeleton は、読み込みが終われば本物に差し替わって消えます。frame の「最初の中身」は、そのまま読み込み中のプレースホルダになる、と理解しておけば十分です。
13.3 ページ内タブ
タブも、frame で作れます。タブの中身を、共通の content frame に読み込む形です。
タブのリンクに data-turbo-frame を付けて、中身を表示する共通の content frame を指します。
<nav>
<%= link_to "概要", overview_project_path(@project), data: { turbo_frame: "tab_content" } %>
<%= link_to "タスク", tasklist_project_path(@project), data: { turbo_frame: "tab_content" } %>
</nav>
<%= turbo_frame_tag "tab_content" do %>
<p>タブを選んでください。</p>
<% end %>
タブのリンクをクリックすると、その行き先から id="tab_content" の frame が取り出され、content frame の中身が差し替わります。タブを切り替えても、ページの他の部分は動きません。
ここで大切なのは、第11章の id 一致ルールです。それぞれのタブの行き先のレスポンスに、id="tab_content" の frame が必要です。つまり、各タブの中身を返すアクションを用意し、そのビューを turbo_frame_tag "tab_content" で包みます。
app/views/projects/overview.html.erb(例)
<%= turbo_frame_tag "tab_content" do %>
<p><%= @project.description %></p>
<% end %>
「タスク」タブのビューも同様に、中身を id="tab_content" の frame で包みます。content frame の id を軸に、どのタブも同じ枠を差し替え合う、という形です。タブの中身はクリックされたときに取得されるので、開いていないタブの中身は読み込まれません。
13.4 サイドバー詳細
第12章では、show.html.erb を render @task の 1 行にしていました。ここで、第12章の終わりで触れた「外枠の中に frame を置く」構成に進めます。
作るのは、左にタスク一覧、右に詳細パネルというサイドバー型の画面です。一覧の行をクリックすると、右の詳細パネルだけが切り替わります。
まず、一覧の各行のリンクに data-turbo-frame を付けて、詳細パネルの frame を指します。
app/views/tasks/index.html.erb(抜粋)
<div class="layout">
<ul class="list">
<% @tasks.each do |task| %>
<li><%= link_to task.title, task_path(task), data: { turbo_frame: "detail" } %></li>
<% end %>
</ul>
<%= turbo_frame_tag "detail" do %>
<p>タスクを選んでください。</p>
<% end %>
</div>
次に、show.html.erb を、id="detail" の frame で包みます。
app/views/tasks/show.html.erb
<%= turbo_frame_tag "detail" do %>
<h2><%= @task.title %></h2>
<%= render @task %>
<% end %>
一覧のリンクをクリックすると、Turbo は task_path(task) を取得し、id="detail" の frame を取り出して、右パネルに差し替えます。詳細の中には、第12章で作った _task(id="task_1" の frame)がそのまま入っているので、サイドバーに表示した詳細の中で、インライン編集もそのまま動きます。frame は入れ子にできるのです。
13.5 エラー時の表示
src 先やリンク先が、404 や 500 を返すこともあります。第11章で見たとおり、frame はレスポンスから同じ id の frame を探します。エラーのときも、レスポンスに同じ id の frame が含まれていないと、frame は壊れます(案内メッセージと例外)。
そのため、エラー用の画面でも、同じ id の frame の中にエラー表示を入れておきます。たとえば 404 のとき、id="detail" の frame の中に「見つかりませんでした」を描く、という形です。こうすれば、サイドバーやタブの中に、きれいにエラーが収まります。
遅延読み込みや画面分割を使うほど、「正常系だけでなく、エラーのレスポンスにも frame を用意する」ことが大切になります。
13.6 <turbo-frame refresh="morph"> を使う場面
第9章で、Turbo 8 の page refresh(同じ URL への visit による再描画)を見ました。frame には、この page refresh が起きたときの振る舞いを指定できます。refresh 属性です。
page refresh が起きると、src を持つ frame は中身を読み込み直します。既定では、frame の中身がまるごと差し替わり、frame の中のスクロール位置や入力中のフォーカスが失われます。
これを避けたいときに、frame へ refresh="morph" を付けます。
<%= turbo_frame_tag "project_tasks", src: tasks_panel_project_path(@project), refresh: :morph do %>
...
<% end %>
refresh: :morph を付けると、page refresh のときの frame の再読み込みが、第9章で見た morph(差分の適用)で行われます。変わった部分だけが書き換わり、frame の中の状態が保たれます。これは一般の src 再取得すべてに効くのではなく、あくまで page refresh のときの振る舞いを変える指定です。
これは、第18章のリアルタイム更新と組み合わせると効きます。サーバーが「このページを refresh してください」という配信(broadcast refresh)を全員へ送ると、各自の frame が morph で最新化され、見ていた位置や入力が保たれます。
第13章では、
srcによる遅延読み込みと、タブ・サイドバーといった画面分割を作りました。frame は入れ子にでき、組み合わせると複雑な画面も組めます。だからこそ、次の第14章では「使いすぎたときにどうなるか」と、Streams や通常遷移へ切り替える判断を扱います。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo Frames リファレンス: https://turbo.hotwired.dev/reference/frames
- Page Refreshes と morphing(Handbook): https://turbo.hotwired.dev/handbook/page_refreshes
第14章 Frames の失敗パターンと設計判断
この章のねらい
第11章から第13章で、Turbo Frames は強力だとわかりました。インライン編集も、遅延読み込みも、サイドバーも作れます。frame は入れ子にもできます。
しかし、強力だからこそ、使いすぎると複雑になります。この章では、「frame を使いすぎたときに現れる兆候」と、「そこで Turbo Streams や通常遷移へ切り替える判断」を学びます。これは第4部の締めであり、第5部 Turbo Streams への橋です。
この章では新しい機能は増やしません。代わりに、ここまで作ったものを振り返り、「どこまで frame でやるべきか」という設計判断の軸を持ちます。
14.1 frame の入れ子が深くなる兆候
第13章で、frame は入れ子にできると見ました。サイドバーの detail frame の中に、インライン編集の task_1 frame が入る、という形です。
入れ子は便利ですが、深くなると、自分でも追えなくなります。「このリンクをクリックすると、どの frame が差し替わるのか」が、すぐに答えられなくなったら危険信号です。
frame 内のリンクは、既定でいちばん内側の frame を差し替えます。data-turbo-frame で対象を変えれば、外側や別の frame も差し替えられます。入れ子が深いほど、この「どこが差し替わるか」の組み合わせが増え、予想とずれます。
対策は、入れ子を浅く保つこと、id を分かりやすく名づけること、そして「このリンクはこの frame」と説明できる範囲に留めることです。説明できなくなったら、設計を見直す合図です。
14.2 URL と画面状態のズレ
第11章で見たとおり、frame 内のリンクで遷移しても、ブラウザの URL は変わりません。これは部分更新の利点ですが、行きすぎると問題になります。
たとえば、サイドバーであるタスクの詳細を開いた状態で、ページをリロードしたとします。URL は一覧のままなので、リロードすると詳細は閉じ、最初の状態に戻ります。URL を誰かに共有しても、相手の画面に同じ詳細は出ません。戻るボタンの挙動も、ユーザーの期待とずれがちです。
つまり、いま見えている画面の状態が、URL から復元できない状態です。これは、frame で作り込むほど起きやすくなります。
その画面の状態を URL に残したいときは、frame の遷移を「URL も進める visit」に格上げします。リンクに data-turbo-action="advance" を付けます。
<%= link_to task.title, task_path(task),
data: { turbo_frame: "detail", turbo_action: "advance" } %>
こうすると、frame を差し替えつつ、ブラウザの URL も更新されます。リロードや共有に耐える画面になります。逆に言えば、「URL に残すべき主要な画面か、残さなくてよい補助的な部分か」を意識して、frame を使い分ける必要があります。
14.3 controller が frame 分岐だらけになる兆候
frame を増やすと、controller 側にも影響が出ます。「通常のリクエストのときはページ全体、frame からのリクエストのときは frame の中身だけ」と返し分けたくなり、リクエストの種類で分岐するコードが増えていきます。
controller のアクションが、リクエストが frame からかどうかで if 分岐だらけになってきたら、それは兆候です。多くの場合、同じビューの中に frame を置いておけば、Turbo が必要な frame だけを取り出してくれます。まずは、リクエストの種類で分岐を増やさずに済まないかを考えます。
分岐が増えてきたら、まず「同じビューを返して Turbo に任せられないか」を考えます。それでも複雑なら、次の 2 つの判断に進みます。
14.4 Streams へ切り替える判断
frame には、はっきりした限界があります。1 回の frame navigation で Turbo が差し替える対象は、1 つの frame だけです(frame の中にある複数の要素は、その frame ごとまとめて差し替わります)。
たとえば、タスクを 1 件削除したときに、離れた場所を同時に更新したいとします。
- 一覧から、その行を消す
- 「残り 12 件」という件数表示を更新する
- 「削除しました」というフラッシュを出す
これは、互いに離れた 3 か所の更新です。1 回の操作で差し替えられる frame は 1 つだけなので、frame だけではこの要求に応えられません。ここが、Turbo Frames から Turbo Streams へ切り替える判断点です。
第7部で使う判断軸を先取りすると、「更新する場所が 1 か所なら Turbo Frames、複数を同時に更新するなら Turbo Streams」です。複数箇所の同時更新が必要になったら、frame で粘らず、Streams へ移ります。これが、次の第5部のテーマです。
14.5 通常遷移に戻す判断
もう 1 つの判断は、逆方向です。そもそも部分更新が要らないなら、frame をやめて通常の遷移に戻す、という判断です。
frame は、「ページの一部を、周りと独立して差し替えたい」ときに価値があります。逆に、ページ全体が変わるような画面遷移(一覧から詳細へ完全に移動する、など)では、frame の独立性は要りません。それなのに frame で作ると、14.2 の URL のズレや、14.1 の入れ子の複雑さだけを抱え込みます。
「URL の問題に悩んでいる」「frame の入れ子と格闘している」けれど、その画面は本当は普通のページでよかった、という場合があります。そのときは、frame を外し、第3部で見た Turbo Drive の通常遷移に戻すのが正解です。Turbo Drive なら、ページ遷移は十分速く、URL も素直に変わります。
判断の基準は、「できるか」ではなく「読みやすく保てるか」です。frame でできるとしても、通常遷移の方が素直なら、そちらを選びます。
第4部では、Turbo Frames を「独立した小さな visit 領域」として学び、その使いどころと、使いすぎの見切りまで見ました。14.4 で触れたとおり、複数箇所を同時に更新したくなったら、次の道具が要ります。第5部では、その Turbo Streams を学びます。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo Drive(Handbook): https://turbo.hotwired.dev/handbook/drive
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
ハンズオン(第4部): インライン編集できる一覧を作る
タスクの一覧・詳細・編集フォームを Turbo Frames で分割し、ページの一部だけを更新できるようにします。
この部の到達状態
- タスク一覧の各行が frame になっている
- 行内でインライン編集でき、キャンセル導線もある
- サイドバーのタスク詳細が
srcで遅延読み込みされ、読み込み中は skeleton が出る - ページ内タブが frame の遅延読み込みで切り替わる
作る・変える
- 行・詳細・フォームを共有 partial にまとめる
- それらを
turbo_frame_tagで frame 化する(id の一致に注意) - サイドバー詳細とタブを
srcで遅延読み込みする
完成条件
- 1 行だけが編集フォームに切り替わり、保存で表示に戻る
- 編集に失敗すると frame 内に 422 のエラー付きフォームが戻る
Relay の現在地
1 か所の部分更新が frame で動く状態。 次の第5部で、複数箇所を同時に更新する Turbo Streams に進みます。
第5部 Turbo Streams
Turbo Streams を使って、HTML による部分更新とリアルタイム更新を学びます。
第15章 Turbo Streams の基本
この章のねらい
第14章の終わりで、Turbo Frames の限界に触れました。1 回の操作で差し替えられる frame は 1 つだけなので、「一覧の行を消し、件数を更新し、フラッシュを出す」といった複数箇所の同時更新には応えられません。
それを解決するのが Turbo Streams です。この章では、Turbo Streams の基本的な仕組み、つまり「サーバーが部分更新の命令を HTML で送る」という考え方を理解します。
この部を貫く軸は「Streams は差し替え命令の入った HTML を送る」です。第3部の visit、第4部の frame が「1 か所をまるごと差し替える」のに対し、Turbo Streams は「どの id に、どの action を、どの HTML で」適用するかを書いた命令を送り、複数の場所を別々の action で同時に操作できます。
15.1 Turbo Streams とは
Turbo Frames と Turbo Streams は、名前は似ていますが、考え方が違います。
Turbo Frames は差し替えでした。<turbo-frame> という枠を置いておき、その枠の中身が、リンクやフォームの操作で差し替わります。きっかけは「枠の中での操作」で、対象は「その枠」です。
Turbo Streams は命令です。サーバーが「この id の要素に、この action を、この HTML で適用せよ」という命令を送ります。受け取ったブラウザは、その命令どおりに DOM を操作します。
命令は、見た目を持ちません。次のような HTML が送られてきます。
<turbo-stream action="append" target="tasks">
<template>
<div id="task_1">最初のタスク</div>
</template>
</turbo-stream>
これは「id="tasks" の要素の末尾に、この <template> の中身を追加せよ(append)」という命令です。<turbo-stream> 自体は画面に表示されず、命令として処理されると消えます。
そして、命令は1 つのレスポンスに複数入れられます。ここが frame との決定的な違いです。frame が 1 か所しか差し替えられなかったのに対し、Streams は離れた複数の場所を、それぞれ別の action で同時に操作できます。第14章で frame では応えられなかった「行を消し、件数を更新し、フラッシュを出す」が、Streams なら 3 つの命令で実現できます(第17章で実装します)。
15.2 8 つの action
Turbo Streams には、8 つの action があります。
| action | 何をするか |
|---|---|
append | target の末尾に追加する |
prepend | target の先頭に追加する |
replace | target の要素自体を置き換える |
update | target の中身だけを置き換える |
remove | target を削除する(中身の HTML は不要) |
before | target の直前に挿入する |
after | target の直後に挿入する |
refresh | ページの再描画を促す(15.6 で扱う) |
replace と update の違いに注意してください。replace は target の要素ごと差し替えます。update は target の要素は残し、その中身だけを差し替えます。タスクの行ごと入れ替えたいなら replace、行の中の一部分だけ変えたいなら update、という使い分けです。
15.3 target と targets
命令の宛先の指定には、2 つの形があります。
1 つは target です。id を 1 つ指定し、その 1 要素を対象にします。
<turbo-stream action="replace" target="task_1"> ... </turbo-stream>
もう 1 つは targets です。CSS セレクタを指定し、当てはまるすべての要素を対象にします。
<turbo-stream action="remove" targets=".done"> ... </turbo-stream>
これは「.done クラスを持つ要素を、すべて削除せよ」という命令です。1 つの命令で複数の要素をまとめて操作したいときに使います。普段は target(単一 id)を使い、まとめて操作したいときに targets(CSS セレクタ)を使う、と覚えておけば十分です。
15.4 turbo_stream.erb と format.turbo_stream
この命令の HTML を、Rails で手書きすることはありません。turbo_stream ヘルパーが組み立ててくれます。
たとえば、タスクを一覧の末尾に追加する命令は、こう書けます。
<%= turbo_stream.append "tasks", @task %>
turbo_stream.append "tasks", @task は、「id="tasks" の末尾に、@task の partial(_task)を append せよ」という命令の HTML を生成します。第12章で作った _task partial が、ここでそのまま使われます。
この命令を返すのは、*.turbo_stream.erb という名前のビューです。たとえば create アクションなら create.turbo_stream.erb を用意します。controller 側では、turbo_stream 形式に応答することを宣言します。
app/controllers/tasks_controller.rb(create の一部)
respond_to do |format|
if @task.save
format.turbo_stream
format.html { redirect_to @task }
end
end
format.turbo_stream があると、Rails は create.turbo_stream.erb を探して返します。
なお、この respond_to は成功時だけを抜き出した断片です。保存に失敗したときは、第8章の契約どおり 422 でフォームを返す必要があります。成功・失敗の両方を含む完全な形と、その System Test は、第16章で扱います。
15.5 MIME type と、なぜ POST 応答で効くか
ここで、1 つの疑問が浮かびます。なぜ Rails は、同じ create アクションで、あるときは通常の HTML を返し、あるときは Turbo Streams を返せるのでしょうか。
鍵は、リクエストの Accept ヘッダーと、Turbo Streams 専用の MIME type です。Turbo Streams の MIME type は text/vnd.turbo-stream.html です。
Turbo は、フォームを送信するとき(POST / PUT / PATCH / DELETE)に、Accept ヘッダーへ自動でこの MIME type を加えます。すると Rails の respond_to は、「このリクエストは Turbo Streams を受け取れる」と判断し、format.turbo_stream の応答を選べます。
これが、Turbo Streams がフォーム送信の応答で効く理由です。そのため Turbo Streams は、基本的に状態を変える送信(POST / PUT / PATCH / DELETE)の応答として使います。通常の GET リクエストには、この MIME type はデフォルトでは付きません。ただし、必要であれば GET でも使えます。リンクなどに data-turbo-stream を付けると、その GET でも Turbo Streams を受け取れるようになります(opt-in)。まずは「状態を変える送信の応答で使う」が基本だと押さえてください。
15.6 refresh action の使いどころ
8 つの action のうち、refresh だけは少し毛色が違います。target を取らず、HTML も運びません。
<turbo-stream action="refresh"></turbo-stream>
これは「ページを再描画せよ」という命令です。受け取ると、ブラウザは第9章で見た page refresh を行います。
再描画の方法とスクロールの扱いは、refresh ストリーム自身に持たせられます。
<turbo-stream action="refresh" method="morph" scroll="preserve"></turbo-stream>
method="morph" で差分適用(morph)、scroll="preserve" でスクロール位置の保持です。第9章で見たレイアウトの <meta name="turbo-refresh-method"> などは、同じ設定をページ単位で与える別の経路で、両者は同じ振る舞いを指します。
refresh は、細かい命令を 1 つずつ組み立てる代わりに、「とにかく最新の状態に揃えてほしい」というときに向きます。とくに、複数ユーザーへ「このページを更新して」と一斉に伝えるリアルタイム更新(broadcast refresh)で効きます。これは第18章で扱います。
第15章では、Turbo Streams を「差し替え命令の入った HTML」として理解しました。命令は 1 つのレスポンスに複数入れられ、複数箇所を同時に操作できます。次の第16章では、Relay の作成・更新・削除を、実際に Turbo Streams で動かします。
参考資料
- Turbo Streams(Handbook): https://turbo.hotwired.dev/handbook/streams
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
- Page Refreshes と morphing(Handbook): https://turbo.hotwired.dev/handbook/page_refreshes
第16章 create / update / destroy を Stream 化する
この章のねらい
第15章で、Turbo Streams は「差し替え命令の入った HTML」だと学びました。この章では、それを Relay の CRUD に組み込みます。タスクの作成・更新・削除を、ページ遷移なしで反映します。
第15章で断片として示した respond_to の完全形(成功は stream、失敗は 422)を、ここで仕上げます。第8章の契約(成功は redirect か stream、失敗は 422)が、Streams でもそのまま効きます。
この章では、一覧画面が次の構造になっている前提で進めます。
app/views/tasks/index.html.erb(抜粋)
<div id="flash"><%= render "layouts/flash" %></div>
<div id="new_task_form">
<%= render "form", task: @task %>
</div>
<div id="tasks">
<%= render @tasks %>
</div>
id="tasks" がタスク一覧の入れ物、id="new_task_form" が新規作成フォームの入れ物、id="flash" がフラッシュの入れ物です。各タスクは、第12章の _task(id="task_1" の frame)で描かれます。index アクションでは、一覧用の @tasks と、新規フォーム用の @task = Task.new を用意しておきます。
なお本書は、Turbo が有効な前提で進めます。各アクションには通常の HTML を返す format.html も書きますが、これは Turbo が使えないクライアント向けのフォールバックです。Turbo 経路と HTML 経路で挙動が食い違わないよう、フラッシュの渡し方や失敗時の戻し先を、経路ごとに揃えておきます。
16.1 create 後に prepend する
タスクを作成したら、一覧の先頭に追加します。create アクションを Turbo Streams に対応させます。
app/controllers/tasks_controller.rb(create の成功側)
if @task.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @task }
end
end
create.turbo_stream.erb で、命令を組み立てます。
app/views/tasks/create.turbo_stream.erb
<%= turbo_stream.prepend "tasks", @task %>
<%= turbo_stream.update "new_task_form" do %>
<%= render "form", task: Task.new %>
<% end %>
1 つ目の命令は、id="tasks" の先頭に、作成したタスク(_task)を prepend します。2 つ目の命令は、id="new_task_form" の中身を、新しい空のフォームに update します。これで、フォームを送信すると、一覧の先頭に新しいタスクが現れ、フォームが空に戻ります。ページ遷移は起きません。
ここで第15章のポイントが効いています。1 つのレスポンスに 2 つの命令を入れて、一覧とフォームという離れた 2 か所を同時に更新しています。
16.2 update 後に replace する
タスクを更新したら、その行だけを新しい内容に差し替えます。update アクションの成功側を Turbo Streams に対応させ、update.turbo_stream.erb を用意します。
app/views/tasks/update.turbo_stream.erb
<%= turbo_stream.replace @task %>
turbo_stream.replace @task は、@task の dom_id(task_1)を target に、_task を描いて要素ごと差し替えます。第12章で、表示も編集も id="task_1" の frame に揃えていたので、その frame がまるごと新しい表示に置き換わります。
replace と update の違い(第15章)を思い出してください。ここでは行(frame)の要素ごと差し替えたいので replace です。
16.3 destroy 後に remove する
タスクを削除したら、その行を消します。destroy.turbo_stream.erb を用意します。
app/views/tasks/destroy.turbo_stream.erb
<%= turbo_stream.remove @task %>
turbo_stream.remove @task は、@task の dom_id(task_1)の要素を削除します。remove は要素を消すだけなので、中身の HTML は要りません(第15章)。
16.4 flash を更新する
操作の結果を、フラッシュで知らせたいこともあります。一覧の入れ物に id="flash" を用意してあるので、ここを更新する命令を足します。
まず、controller でフラッシュを設定します。ここで注意が要ります。Turbo Streams の応答ではページ遷移しないので flash.now を使いますが、HTML フォールバックは redirect_to で遷移するので、リダイレクト後まで残る flash を使う必要があります。flash.now はそのリクエスト限りで消えるため、リダイレクトでは残りません。そこで、経路ごとに分けて設定します。
app/controllers/tasks_controller.rb(create の成功側)
if @task.save
respond_to do |format|
format.turbo_stream do
flash.now[:notice] = "タスクを作成しました。"
render :create
end
format.html { redirect_to @task, notice: "タスクを作成しました。" }
end
end
format.turbo_stream の側では flash.now を立ててから create.turbo_stream.erb を描き(その中で flash を更新します)、format.html の側では redirect_to の notice: でリダイレクト後に残るフラッシュを渡します。
そして、create.turbo_stream.erb に flash を更新する命令を足します。
app/views/tasks/create.turbo_stream.erb(追記)
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
これで、作成時に「一覧へ prepend」「フォームを空に update」「flash を update」という 3 つの命令が、1 レスポンスで送られます。第14章で frame では応えられなかった「複数箇所の同時更新」が、これで実現できました。
16.5 エラー時はフォームを差し替える
ここまでは成功側でした。失敗側を仕上げます。第8章の契約どおり、失敗時は 422 で返します。Streams の場合は、フォームを「エラー付きのフォーム」に差し替える命令を、422 で返します。
app/controllers/tasks_controller.rb(create の全体)
def create
@task = Task.new(task_params)
if @task.save
respond_to do |format|
format.turbo_stream do
flash.now[:notice] = "タスクを作成しました。"
render :create
end
format.html { redirect_to @task, notice: "タスクを作成しました。" }
end
else
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.update(
"new_task_form", partial: "tasks/form", locals: { task: @task }
), status: :unprocessable_entity
end
format.html do
@tasks = Task.all
render :index, status: :unprocessable_entity
end
end
end
end
失敗側の Turbo Streams は、id="new_task_form" の中身を、エラー付きのフォーム(@task にはエラーが入っている)に update します。ステータスは 422 です。status: :unprocessable_entity を付けるのは、第8章で見た契約のためです。Turbo は、422 でも Turbo Streams の応答であれば命令として処理します。
HTML フォールバックの戻し先にも注意します。この章のフォームは一覧ページ(index)に置いた inline フォームなので、失敗時は :new ではなく :index を 422 で返します。:new を返すと、#flash / #new_task_form / #tasks を持つこの章の画面構造から外れてしまうからです。render :index ではエラーの入った @task がフォームに使われ、一覧用の @tasks も必要なので、ここで用意しています。
成功時は一覧に追加、失敗時はフォームをエラー付きに差し替え。どちらもページ遷移せず、必要な場所だけが変わります。
16.6 この章の System Test
作成(成功・失敗)と削除を、System Test で確認します。
test/system/tasks_stream_test.rb
require "application_system_test_case"
class TasksStreamTest < ApplicationSystemTestCase
setup do
@project = Project.create!(name: "テスト用プロジェクト")
end
test "作成すると一覧の先頭に追加される" do
visit tasks_path
fill_in "Title", with: "新しいタスク"
fill_in "Project", with: @project.id
click_on "Create Task"
within "#tasks" do
assert_text "新しいタスク"
end
end
test "タイトルが空だとフォームにエラーが出る" do
visit tasks_path
fill_in "Title", with: ""
fill_in "Project", with: @project.id
click_on "Create Task"
within "#new_task_form" do
assert_selector "input[name='task[title]']"
assert_text "prohibited"
end
end
test "削除すると一覧から消える" do
task = @project.tasks.create!(title: "消されるタスク")
visit tasks_path
within "##{dom_id(task)}" do
click_on "削除"
end
assert_no_text "消されるタスク"
end
end
いずれも、ページ遷移せずに一覧やフォームが変わることを確認しています。作成成功は #tasks の中に出ること、失敗は #new_task_form の中にエラーが出ること、削除は一覧から消えることを、それぞれ見ています。
第16章では、CRUD を Turbo Streams で動かし、作成時に複数箇所を同時更新できることを見ました。次の第17章では、その「複数箇所の同時更新」を、件数・空状態・partial 共通化・id 設計の観点で深めます。
参考資料
- Turbo Streams(Handbook): https://turbo.hotwired.dev/handbook/streams
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
- Rails ガイド「Action View の概要」: https://guides.rubyonrails.org/action_view_overview.html
第17章 複数箇所を同時に更新する
この章のねらい
第16章で、作成時に「一覧へ prepend」「フォームを update」「flash を update」という 3 つの命令を、1 レスポンスで送りました。この章では、その「複数箇所の同時更新」を、実務で必要になる形へ広げます。
件数バッジの更新、最後の 1 件を消したときの空状態の表示、partial の共通化、そして命令の宛先を支える dom_id の設計です。最後に、画面が遷移なしで変わるときのアクセシビリティの入口にも触れます。
17.1 1 レスポンスに複数 stream を含める
まず、削除を例に、複数の命令を 1 レスポンスにまとめます。タスクを 1 件削除したら、次の 3 か所を同時に更新したいとします。
- 一覧から、その行を消す
- 件数バッジを、新しい件数にする
- 「削除しました」とフラッシュを出す
destroy.turbo_stream.erb に、命令を並べます。
app/views/tasks/destroy.turbo_stream.erb
<%= turbo_stream.remove @task %>
<%= turbo_stream.update "task_count" do %><%= @tasks.size %><% end %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
第14章で「frame では 1 か所しか差し替えられないので応えられない」とした要求が、Turbo Streams なら 3 つの命令で実現できます。命令を並べるだけです。
ここで使っている @tasks は、その一覧で表示しているタスクの集まり(現在の一覧スコープ)です。stream を返す前に、controller で @tasks に index と同じスコープを入れておきます。こうすると、最初の表示と部分更新が、同じ一覧を指せます。
17.2 カウンター更新
件数バッジは、一覧のどこかに置いた要素です。id を付けておきます。
app/views/tasks/index.html.erb(抜粋)
<span id="task_count"><%= @tasks.size %></span> 件
更新は、17.1 で見たとおり turbo_stream.update "task_count" です。作成時にも増やしたいので、create.turbo_stream.erb にも同じ命令を足します。
<%= turbo_stream.update "task_count" do %><%= @tasks.size %><% end %>
件数は、表示している一覧(@tasks)の数を出します。全タスク数(Task.count)を出すと、絞り込みやプロジェクトごとの一覧に進んだとき、画面の件数と食い違います。@tasks を使い、index と stream で同じスコープを指すのがポイントです。プロジェクトごとの一覧なら、そのプロジェクトのタスクを @tasks に入れます。
17.3 空状態の表示
ここで、削除に 1 つ落とし穴があります。remove で行を消すと、最後の 1 件を消したとき、一覧の入れ物が空っぽになります。「タスクはありません」のような案内が出ず、ただの空白になってしまいます。
これを防ぐには、一覧の領域を、空のときの表示も含めて 1 つの partial にまとめ、変化のたびにその領域ごと描き直すのが簡単です。
app/views/tasks/_tasks.html.erb
<div id="tasks">
<% if tasks.any? %>
<%= render tasks %>
<% else %>
<p>タスクはありません。</p>
<% end %>
</div>
そして、削除では行だけを remove するのではなく、この領域を replace で描き直します。
app/views/tasks/destroy.turbo_stream.erb(空状態に対応した形)
<%= turbo_stream.replace "tasks", partial: "tasks/tasks", locals: { tasks: @tasks } %>
<%= turbo_stream.update "task_count" do %><%= @tasks.size %><% end %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
ここでも @tasks(現在の一覧スコープ)を渡しています。Task.all を直接書くと、絞り込みやページングに進んだときに、一覧と食い違います。一覧スコープは controller の @tasks に一本化し、index と stream の両方でそれを使います。
remove は 1 行だけを正確に消せる軽い方法ですが、空状態を別に面倒見る必要があります。領域ごと replace する方法は、一覧をまるごと描き直すので少し重い代わりに、空状態を自然に扱えます。「精密に消すか、領域ごと描き直すか」は設計判断です。空状態のような分岐が要るときは、領域ごと描き直す方が素直です。
17.4 partial の共通化
17.3 で、一覧の領域を _tasks partial にまとめました。ここで効いてくるのが、partial の共通化です。
この _tasks partial は、最初の一覧表示(index)でも、削除後の描き直し(stream)でも、同じものを使います。
app/views/tasks/index.html.erb(抜粋)
<%= render "tasks", tasks: @tasks %>
もし、一覧の見た目を一覧画面と stream で別々に書いていたら、修正のたびに両方を直す羽目になります。「最初の表示」と「部分更新」で同じ partial を使うことが、Turbo Streams を使った設計の土台です。第16章で _task(1 件の表示)を一覧と stream で共通化したのと、同じ考え方です。
17.5 id 設計と dom_id
ここまで、turbo_stream.replace @task や turbo_stream.remove @task のように、命令の宛先をモデルから指定してきました。これを支えているのが dom_id です。
dom_id(task) は、"task_1" のような文字列を返します。第12章で _task を turbo_frame_tag task で囲んだとき、その frame の id は dom_id(task) = "task_1" でした。そして stream の turbo_stream.replace @task も、同じ dom_id(@task) を target にします。表示側の id と、命令側の target が、dom_id で自動的に一致するのです。
だから、id を手で書いてはいけません。<div id="task_1"> のように手書きすると、モデルの id がずれた瞬間に target と合わなくなります。dom_id を使えば、表示も命令も同じ規則で id が決まり、ずれません。
dom_id には、接頭辞も付けられます。
dom_id(task) # => "task_1"
dom_id(task, :edit) # => "edit_task_1"
dom_id(Task.new) # => "new_task"
同じレコードに複数の枠(表示用、編集用など)を持たせたいときは、接頭辞で id を分けます。コンテナの id("tasks"、"task_count"、"flash")と合わせて、id の付け方を最初に決めておくと、stream の宛先がぶれません。
17.6 アクセシブルな更新通知の入口
ここで、アクセシビリティ(a11y)に触れます。a11y は accessibility の略で、最初の a と最後の y のあいだに 11 文字あることからこう書きます。誰にとっても使える Web にするための配慮のことです。本書では以降、この「a11y」という表記を使います。
Turbo Streams は、ページ遷移なしで画面を書き換えます。目で見ているユーザーには自然ですが、スクリーンリーダーを使うユーザーには、更新が起きたことが伝わらないことがあります。画面の一部が静かに変わるだけだからです。
これを補う入口が、aria-live です。更新を読み上げてほしい領域に付けます。たとえばフラッシュの入れ物に付けると、フラッシュが更新されたとき、その内容が読み上げられます。
<div id="flash" role="status" aria-live="polite">
<%= render "layouts/flash" %>
</div>
role="status" は、aria-live="polite"(手が空いたときに読み上げる)を含む役割です。操作の結果を伝えるフラッシュに向いています。そのため、role="status" だけでも読み上げは働きます。上の例で aria-live="polite" を併記しているのは、意図を読み手に明示するためで、動作上は重ねなくても構いません。
ここでは「部分更新には読み上げの配慮が要る」という入口だけ押さえます。フォーカスの移動や、件数・エラーの読み上げといった本格的な作り込みは、第7部(実務で使う Hotwire UI パターン)と、その a11y チェックリストで扱います。
第17章では、複数箇所の同時更新を、件数・空状態・partial 共通化・
dom_id・読み上げの観点で設計しました。ここまでは「自分の操作」がきっかけでした。次の第18章では、Action Cable を使って、「他のユーザーの操作」をきっかけに、同じ更新を全員へ配信します。
参考資料
- Turbo Streams(Handbook): https://turbo.hotwired.dev/handbook/streams
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
- Rails API:
dom_id(ActionView::RecordIdentifier): https://api.rubyonrails.org/classes/ActionView/RecordIdentifier.html - MDN: ARIA live regions: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
第18章 Action Cable でリアルタイム更新する
この章のねらい
第16章と第17章では、「自分の操作」をきっかけに画面を更新しました。フォームを送信した本人の画面が、ページ遷移なしで変わる仕組みです。
この章では、それを「他のユーザーの操作」へ広げます。あるユーザーがタスクを追加したら、同じプロジェクトを見ている全員の画面に、そのタスクがリアルタイムで現れる、という形です。
仕組みの土台は同じです。サーバーが Turbo Streams の命令を送り、ブラウザがそれを実行します。違うのは、命令を届ける経路です。これまではフォーム送信の応答でしたが、今回は Action Cable を通じて、購読している全員へ配信(broadcast)します。
18.1 broadcast の基本
リアルタイム更新を受け取るには、まず画面が「どの配信を聞くか」を宣言します。turbo_stream_from を使います。
app/views/tasks/index.html.erb(抜粋)
<%= turbo_stream_from @project %>
turbo_stream_from @project は、<turbo-cable-stream-source> という要素を描き、@project に対応する配信を Action Cable で購読し始めます。この宣言を置いたページは、@project 宛てに broadcast された Turbo Streams の命令を受け取り、そのまま実行します。
受け取る命令の中身は、第15章〜第17章で見たものと同じ「差し替え命令の入った HTML」です。経路が Action Cable に変わっただけで、ブラウザのやることは変わりません。
18.2 model callback と broadcast
次に、配信する側です。タスクが作成・更新・削除されたら、その内容を @project 宛てに配信します。これはモデルに宣言できます。
app/models/task.rb(追記)
class Task < ApplicationRecord
belongs_to :project
# ... 既存の関連やバリデーション ...
broadcasts_to ->(task) { task.project }, inserts_by: :prepend
end
broadcasts_to は、レコードの作成・更新・削除のたびに、Turbo Streams を自動で配信します。配信先(streamable)は、ラムダが返すもの(ここでは task.project)です。18.1 で turbo_stream_from @project を宣言したページが、これを受け取ります。
既定では、作成で append、更新で replace、削除で remove が配信されます。ここでは inserts_by: :prepend を指定し、第16章と同じく一覧の先頭へ追加するようにしています。配信される HTML は、サーバー側で _task partial を描いたものです。
これで、誰かがタスクを作ると、同じプロジェクトを開いている全員の一覧に、そのタスクが先頭へ追加されます。
18.3 controller からの broadcast
モデルの callback は「1 レコードの変化」を配信するのに向いています。しかし、第17章で見た件数バッジのように、レコード自体ではない部分も、他のユーザーへ更新したいことがあります。
そうした場合は、controller などから明示的に broadcast します。
Turbo::StreamsChannel.broadcast_update_to(
@project, target: "task_count", html: @project.tasks.size
)
broadcast_update_to は、@project を購読している全員へ、「id="task_count" を、この内容に update せよ」という命令を配信します。broadcast_append_to や broadcast_replace_to など、第15章の各 action に対応したメソッドがあります。
自分の操作の応答(第16・17章)では、件数も flash も同じレスポンスにまとめて返していました。リアルタイム配信では、行は model callback、件数は controller からの broadcast、というように、配信したいものごとに送る形になります。
18.4 配信範囲の設計
ここで大切なのが、「誰に届くか」です。届く範囲は、配信先(streamable)と購読先が一致した相手です。turbo_stream_from @project を宣言した人だけが、@project 宛ての配信を受け取ります。
だから、配信先を何にするかが設計の要です。プロジェクト単位で見せたい更新なら、@project を配信先にします。これを、たとえば「全プロジェクト共通」のような広すぎる配信先にすると、関係のないユーザーにまで更新が飛びます。無駄な通信が増えるうえ、見せるべきでない情報が混ざる危険もあります。
「この更新は、誰に届くべきか」を先に決め、それに合った streamable を選ぶ。これが配信範囲の設計です。
18.5 認可の入口
配信範囲と認可は、別の話です。混同しないでください。
turbo_stream_from @project が生成する購読名は、署名されています。これは購読名の改ざんを防ぐもので、第三者が勝手に別の配信を聞き取ることを難しくします。しかし、これは認可ではありません。「そのユーザーが、そのプロジェクトを見てよいか」を判断しているわけではないからです。
アクセス制御は、別途 controller やモデルの側で行います。たとえば「ログイン済みか」「そのプロジェクトのメンバーか」を確認し、見てよいユーザーにだけ turbo_stream_from を描く、配信する内容に秘密情報を含めない、といった対策です。
本書のサンプル Relay は単一チーム前提なので、ここは最小限に留めます。Hotwire の部分更新・broadcast でも認可を崩さない設計は、第31章でまとめて扱います。
18.6 実務での注意点
リアルタイム更新は強力ですが、いくつか注意点があります。
- 配信のたびに HTML を描く。broadcast は partial をサーバーで描画します。保存のたびに重い描画が走ると、レスポンスが遅くなります。broadcast の各 action には、裏のジョブで描く非同期版(
broadcast_append_later_toやbroadcast_replace_later_toなどの*_later系)があるので、重い配信はそちらに逃がします(broadcasts_toも内部でこれらを使います)。 - N+1 に注意。配信用の partial でも、関連を引けば N+1 が起きます。これは第30章で扱います。
- 細かすぎる配信を見直す。変化のたびに細かい命令を配信すると、数が増えます。「とにかく最新に揃えたい」だけなら、第15章の
refreshを使った broadcast refresh が向きます。サーバーが「このページを refresh して」と配信すると、各自の画面が morph(第9章)で最新化されます。 - 1 件ずつの保存では callback が走る。
broadcasts_toはレコードの保存(save/destroy)ごとに動くので、seed で 1 件ずつ作るときにも配信が走ります。一方、update_all/delete_allのような SQL の一括更新は callback を通らないので、配信されません。「配信してほしいのに飛ばない」「飛んでほしくないのに飛ぶ」のどちらの取りこぼしにも注意します。
第18章で、第5部を締めます。Turbo Streams を「差し替え命令の入った HTML」として理解し、CRUD への組み込み、複数箇所の同時更新、そして Action Cable による複数ユーザーへの配信まで見ました。次の第6部では、ここまでサーバー主体で進めてきた更新に対し、サーバーを介さない振る舞いを足す Stimulus を学びます。
参考資料
- Turbo Streams(Handbook): https://turbo.hotwired.dev/handbook/streams
- turbo-rails(Broadcastable / Streams): https://github.com/hotwired/turbo-rails
- Rails ガイド「Action Cable の概要」: https://guides.rubyonrails.org/action_cable_overview.html
- Page Refreshes と morphing(Handbook): https://turbo.hotwired.dev/handbook/page_refreshes
ハンズオン(第5部): 画面遷移なしで追加・更新・削除する
Turbo Streams で、Relay の作成・更新・削除を画面遷移なしに反映し、最後はリアルタイム更新まで広げます。
この部の到達状態
- タスクの作成・更新・削除が prepend / replace / remove で反映される
- 1 レスポンスで、行・件数・flash・空状態を同時に更新できる
- コメントの追加・削除が部分更新で動く
- Action Cable で、同じタスクを見ている全員にライブで反映される
作る・変える
format.turbo_streamで create / update / destroy を stream 化する- 複数 stream を 1 レスポンスに束ね、件数・空状態・flash を同時に更新する
broadcasts_to/turbo_stream_fromで broadcast を足す
完成条件
- 2 つの画面を開き、片方の操作がもう片方に反映される
- 最後の 1 件を消すと空状態が表示される
- これらを System Test で確認できる
Relay の現在地
部分更新・複数同時更新・リアルタイム更新が揃った状態。 次の第6部で、サーバー不要の振る舞いを Stimulus で足します。
第6部 Stimulus
Stimulus を「HTML に振る舞いを足す小さな JavaScript」として学びます。状態は JavaScript の中ではなく HTML 側(data 属性)に持つので、Turbo がページや frame を差し替えても振る舞いは保たれます。controller / action / target から Values / Classes / Outlets、そして外部ライブラリとの連携までを扱います。
第19章 Stimulus の基本
この章のねらい
第3部から第5部まで、画面の更新はすべてサーバーが担ってきました。リンク、フォーム、Turbo Streams、broadcast。どれも「サーバーが HTML を返し、ブラウザがそれを反映する」仕組みでした。
しかし、サーバーに問い合わせるまでもない振る舞いもあります。メニューの開閉、文字数のカウント、入力欄へのフォーカス。こうした「その場で完結する振る舞い」を担うのが Stimulus です。
この章では、Stimulus の思想と、もっとも基本的な書き方(controller と data-controller)、そして Turbo との関係を学びます。
この部を貫く軸は「Stimulus は HTML に振る舞いを足す。状態は HTML に置く」です。Stimulus はサーバー往復が要らない振る舞いだけを HTML に足し、状態は JavaScript の中ではなく HTML 側(data 属性)に持ちます。だから Turbo がページや frame を差し替えても、振る舞いは保たれます。
19.1 Stimulus の思想
Turbo が広まる前、Rails で少し凝った操作を足すときは、ページ読み込み時に JavaScript を実行して、要素を探して、イベントを結びつける、という書き方が一般的でした。
document.addEventListener("DOMContentLoaded", () => {
document.querySelector("#menu-button").addEventListener("click", toggleMenu)
})
この書き方には、Turbo と相性が悪い問題があります。Turbo はページを丸ごと再読み込みせず、<body> を差し替えて遷移します(第7章)。新しい document を初回ロードするわけではないので、DOMContentLoaded は最初の読み込みのときにしか走りません。結果、Turbo で遷移した先では、結びつけたはずの振る舞いが動かなくなります。
Stimulus は、この問題を別の発想で解きます。「どの要素に、どの振る舞いを足すか」を、JavaScript で探しに行くのではなく、HTML 側に書いておくのです。
<div data-controller="menu">
...
</div>
こう書いておくと、Stimulus が data-controller="menu" の付いた要素を見つけ、対応する振る舞いを自動で結びつけます。要素が追加されれば結びつけ、取り除かれれば解除します。これは Turbo の差し替えのあとでも自動で働きます。HTML が主役で、JavaScript はそれに従う。これが Stimulus の思想です。
19.2 controller
Stimulus の振る舞いは、controllerという単位で書きます。controller は、@hotwired/stimulus の Controller を継承した JavaScript のクラスです。
例として、要素にフォーカスを当てるだけの小さな controller を作ります。
app/javascript/controllers/autofocus_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.focus()
}
}
connect() は、この controller が要素に結びついたときに呼ばれるメソッドです。this.element は、結びついた相手の要素を指します。つまりこの controller は、「結びついたら、その要素にフォーカスを当てる」という振る舞いです。
Rails には Stimulus 用のジェネレータもあります。
bin/rails generate stimulus autofocus
これで autofocus_controller.js の雛形が作られます。
19.3 data 属性
作った controller を要素に結びつけるには、HTML 側で data-controller 属性に controller の名前を書きます。
app/views/tasks/_form.html.erb(抜粋)
<%= form.text_field :title, data: { controller: "autofocus" } %>
これで、このタイトル入力欄に autofocus controller が結びつき、表示されると同時にフォーカスが当たります。
data-controller の値が autofocus なら、autofocus_controller.js が対応します。ファイル名と data-controller の名前が対応するのが基本ルールです。1 つの要素に複数の controller を結びつけたいときは、空白で区切って並べます(data-controller="autofocus tooltip")。
19.4 Rails での配置
Stimulus の controller は、app/javascript/controllers/ に置きます。第6章で見たとおり、このディレクトリの controller は自動で登録されます(eagerLoadControllersFrom)。だから、ファイルを所定の場所に置けば、data-controller で名前を指すだけで動きます。手で登録する必要はありません。
ファイル名と名前の対応は、次のとおりです。
autofocus_controller.js→data-controller="autofocus"controllers/users/list_item_controller.js→data-controller="users--list-item"
_controller.js の部分は付けず、それより前の部分が名前になります。残った部分のうち、サブディレクトリの区切り(/)は -- に、語の区切りの _ は - に変換されます。上の例では、users/ が users-- に、list_item が list-item になっています。
19.5 Turbo との関係
Stimulus の connect() と disconnect() は、Turbo の動きと噛み合っています。これが、Stimulus を使う最大の理由です。
connect()… controller が要素に結びついたときに呼ばれるdisconnect()… controller が要素から外れたときに呼ばれる
ここでいう「結びつく・外れる」は、最初のページ読み込みだけでなく、Turbo の差し替えのたびに起こります。Turbo Drive で visit して <body> が差し替わったとき、新しい要素に connect() が呼ばれます。frame が差し替わったときも、Turbo Streams で要素が追加・削除されたときも同じです。
19.1 で見た DOMContentLoaded は、最初の一度しか発火しませんでした。一方 Stimulus の connect() は、Turbo が画面を差し替えるたびに呼ばれます。だから、Turbo で動くアプリでは、DOMContentLoaded で書いた振る舞いは動かなくなり、Stimulus の controller は動き続けます。
逆に、disconnect() で後始末をしないと、要素が消えても残骸が残ることがあります。第9章で見たキャッシュとも関わるこの後始末は、外部ライブラリを扱う第22章で詳しく扱います。
第19章では、Stimulus を「HTML に振る舞いを足す仕組み」として理解し、controller・
data-controller・Turbo との関係を見ました。次の第20章では、Stimulus の中心概念である controller・action・target を、手を動かして覚えます。
参考資料
- Stimulus Handbook(The Origin of Stimulus ほか): https://stimulus.hotwired.dev/handbook/introduction
- Stimulus リファレンス(Controllers / Lifecycle): https://stimulus.hotwired.dev/reference/controllers
- Stimulus リファレンス(Lifecycle Callbacks): https://stimulus.hotwired.dev/reference/lifecycle-callbacks
第20章 Controller / Action / Target
この章のねらい
第19章では、controller を要素に結びつけ、connect() で振る舞いを足しました。しかし、connect() だけでは「結びついたとき」しか動けません。
実際の UI では、「ボタンを押したら」「入力したら」といった操作に反応し、「この要素の値を読む」「あの要素を書き換える」といった要素の出し入れが必要です。これを担うのが action と target です。
この章では、Stimulus の 3 つの中心概念、controller・action・target を、Relay のタスク説明欄に「文字数カウンタ」を作りながら覚えます。
20.1 controller の作成
作るのは、入力された文字数を数えて表示するカウンタです。controller を作ります。
bin/rails generate stimulus counter
app/javascript/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
}
まだ空です。ここに、action と target を足していきます。
20.2 action の接続
カウンタは「入力されたとき」に動いてほしいので、入力イベントを controller のメソッドに結びつけます。これが action です。
action は、HTML 側で data-action 属性に書きます。書式は イベント->controller名#メソッド名 です。
app/views/tasks/_form.html.erb(抜粋)
<div data-controller="counter">
<%= form.text_area :description, data: { action: "input->counter#count" } %>
</div>
input->counter#count は、「input イベントが起きたら、counter controller の count メソッドを呼ぶ」という意味です。
controller 側に count メソッドを足します。
app/javascript/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
count(event) {
console.log(event.target.value.length)
}
}
これで、説明欄に入力するたびに、文字数が Console に出ます。event には、起きたイベントが渡されます。
なお、要素ごとに既定のイベントが決まっており、textarea の既定は input です。そのため、この場合は data-action="counter#count" とイベントを省いても同じく動きます。最初は省かずに書くと、何のイベントかが読み取りやすくなります。
20.3 target の参照
文字数を Console ではなく、画面に表示したいところです。表示先の要素を、controller から参照できるようにします。これが target です。
target も、HTML 側で宣言します。data-controller名-target 属性に、target の名前を書きます。
<div data-controller="counter">
<%= form.text_area :description, data: { counter_target: "field", action: "input->counter#count" } %>
<span data-counter-target="output">0</span> 文字
</div>
入力欄を field、表示先を output という target にしました(target 名は、input イベントと紛れないよう field にしています)。controller 側では、使う target の名前を static targets で宣言します。すると、this.fieldTarget、this.outputTarget で要素を参照できます。
app/javascript/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["field", "output"]
connect() {
this.count()
}
count() {
this.outputTarget.textContent = this.fieldTarget.value.length
}
}
this.fieldTarget が入力欄、this.outputTarget が表示先です。count は、入力欄の文字数を表示先に書き込みます。connect() でも count() を呼んでおけば、最初の表示時にも正しい文字数が出ます(編集画面で既存の説明があるときに効きます)。
これで、入力するたびに画面の文字数が更新されます。サーバーへの問い合わせは一切ありません。
20.4 複数 target
target は、同じ名前を複数の要素に付けられます。その場合は、複数形の this.名前Targets で、配列として受け取れます。
たとえば、複数のチェックボックスをまとめて操作する controller を考えます。
<div data-controller="bulk">
<input type="checkbox" data-action="change->bulk#toggleAll">すべて選択
<input type="checkbox" data-bulk-target="item">
<input type="checkbox" data-bulk-target="item">
<input type="checkbox" data-bulk-target="item">
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["item"]
toggleAll(event) {
this.itemTargets.forEach((item) => {
item.checked = event.target.checked
})
}
}
this.itemTargets は、data-bulk-target="item" が付いたすべての要素の配列です。「すべて選択」を切り替えると、3 つのチェックボックスがまとめて変わります。単一なら this.itemTarget、複数なら this.itemTargets、と単数・複数で使い分けます。
20.5 よくある命名ミス
controller・action・target は、名前で結びつきます。だから、名前のずれが、そのままつまずきになります。代表的なものを挙げます。
- controller 名とファイル名のずれ。
data-controller="counter"ならcounter_controller.jsです。ここがずれると、controller がまったく結びつきません。 - action の書式ミス。
イベント->controller名#メソッド名の->や#を間違える、controller 名をタイプミスする、と動きません。 - target の宣言漏れ。
data-counter-target="output"を付けても、controller 側のstatic targetsに"output"を書き忘れると、this.outputTargetで「target が見つからない」エラーになります。 - target の属性名のずれ。target の属性は
data-controller名-targetです。data-counter-targetをdata-targetなどと書くと、結びつきません。 - 単数・複数の取り違え。1 つの要素なら
this.xTarget、複数ならthis.xTargetsです。複数あるのに単数で書くと、最初の 1 つしか取れません。
これらは、第29章のデバッグでも改めて扱います。困ったら「名前が全部そろっているか」をまず確認してください。
20.6 この章の System Test
文字数カウンタが動くことを、System Test で確認します。Stimulus はブラウザで動くので、JavaScript が動く System Test で確かめます。
test/system/counter_test.rb
require "application_system_test_case"
class CounterTest < ApplicationSystemTestCase
setup do
@project = Project.create!(name: "テスト用プロジェクト")
end
test "説明の文字数が表示される" do
visit new_task_path
fill_in "Description", with: "hello"
assert_selector "[data-counter-target='output']", text: "5"
end
end
fill_in で説明欄に入力すると、input イベントが発火し、count が呼ばれ、output target に文字数が出ます。assert_selector で、その表示が 5 になっていることを確認します。
第20章では、controller・action・target で、操作に反応して要素を出し入れする振る舞いを作りました。次の第21章では、controller に設定値を渡す Values、状態を表す CSS Classes、他の controller とつなぐ Outlets を学び、「状態を HTML に置く」という第6部の軸を深めます。
参考資料
- Stimulus リファレンス(Actions): https://stimulus.hotwired.dev/reference/actions
- Stimulus リファレンス(Targets): https://stimulus.hotwired.dev/reference/targets
- Stimulus リファレンス(Controllers): https://stimulus.hotwired.dev/reference/controllers
第21章 Values / Classes / Outlets
この章のねらい
第20章で、controller・action・target を覚えました。これで操作に反応できますが、まだ足りないものがあります。
たとえば「3 秒後に消す」という controller を作るとき、その「3 秒」をどこに持たせるでしょうか。JavaScript に直接書くと、画面ごとに秒数を変えられません。また、付け外しする CSS クラス名を JavaScript に直書きすると、見た目の都合が JavaScript に漏れます。
この章では、こうした設定値・クラス名・controller 同士の接続を、HTML 側に持たせる仕組みを学びます。Values・CSS Classes・Outlets です。これは第6部の軸「状態を HTML に置く」の核心です。
21.1 Values
Values は、controller に渡す設定値です。HTML の data 属性で渡し、controller では型付きで受け取れます。
例として、一定時間で自動的に消えるトースト(第27章で使います)を作ります。
app/javascript/controllers/toast_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 3000 } }
connect() {
this.timeout = setTimeout(() => this.element.remove(), this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
}
static values で、delay という数値の値を宣言します。default: 3000 は、指定がないときの既定値です。controller の中では this.delayValue で読めます。
HTML 側では、data-controller名-値の名前-value で渡します。
<div data-controller="toast" data-toast-delay-value="5000">
保存しました。
</div>
これで、このトーストは 5000 ミリ秒(5 秒)で消えます。data-toast-delay-value を書かなければ、既定の 3000 ミリ秒です。秒数という設定が、JavaScript ではなく HTML 側にあります。だから、画面ごとに違う秒数を、サーバーの都合で指定できます。
値の型は、Number のほか String・Boolean・Array・Object が使えます。また、値が変わったときに呼ばれるコールバックもあります。delay なら delayValueChanged() という名前のメソッドを定義しておくと、値の変化に反応できます。
値の名前が複数語のときは、属性名はハイフン区切りになります。たとえば refreshInterval という値なら、属性は data-toast-refresh-interval-value です。
21.2 CSS Classes
controller の中で CSS クラスを付け外しすることはよくあります。このとき、クラス名を JavaScript に直書きすると、見た目の都合が JavaScript に漏れます。CSS Classes を使うと、クラス名も HTML 側に置けます。
開閉する controller を例にします。
app/javascript/controllers/disclosure_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["hidden"]
toggle() {
this.contentTarget.classList.toggle(this.hiddenClass)
}
}
static classes で hidden というクラスを宣言し、this.hiddenClass で読みます。実際のクラス名は HTML 側で指定します。
<div data-controller="disclosure" data-disclosure-hidden-class="d-none">
<button data-action="disclosure#toggle">開閉</button>
<div data-disclosure-target="content">詳細</div>
</div>
data-disclosure-hidden-class="d-none" で、付け外しするクラスを d-none だと指定しています。JavaScript は「hiddenClass を付け外しする」としか書いておらず、それが具体的にどのクラスかは HTML(と CSS)の都合です。CSS フレームワークを変えても、JavaScript は直さずに済みます。
21.3 Outlets
Outlets は、controller から別の controllerを参照する仕組みです。離れた場所にある controller 同士をつなぎたいときに使います。
たとえば、検索フォームの controller から、一覧の controller を操作したい、といった場面です(第23章で扱います)。
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static outlets = ["list"]
refresh() {
this.listOutlet.reload()
}
}
static outlets で list を宣言し、HTML 側で CSS セレクタを指定します。
<div data-controller="search" data-search-list-outlet="#tasks">
...
</div>
<div id="tasks" data-controller="list">
...
</div>
ここで大切なのは、data-search-list-outlet のセレクタ(#tasks)が指す要素が、それ自身 list controller である(data-controller="list" を持つ)ことです。Outlet は「セレクタで要素を探し、その要素に結びついた controller を参照する」仕組みだからです。
this.listOutlet で、#tasks に結びついた list controller のインスタンスを参照し、そのメソッド(reload など)を呼べます。Outlets は強力ですが、controller 同士を密に結びつけるので、使いすぎると関係が追いにくくなります。「本当に controller 間の連携が要るか」を見極めて使います(Outlets は Stimulus 3.2 以降の機能です)。
21.4 状態を持つべきかの判断
Values・Classes・Outlets を学ぶと、controller に何でも持たせたくなります。しかし、Stimulus の思想は「状態は HTML に置く」でした。
判断の目安は、こうです。
- 設定値(秒数、URL、上限など)は、Values で HTML に置く
- 付け外しするクラス名は、CSS Classes で HTML に置く
- いまの状態(開いているか、選択中かなど)も、できるだけ DOM に表す(クラスや属性で)
- controller のインスタンス変数に状態をため込むのは、最小限にする
controller の中(JavaScript のインスタンス変数)に状態をため込むと、Turbo がページや frame を差し替えたときに、その状態は失われます。次の 21.5 で、その理由を見ます。
21.5 HTML 側に情報を置く利点
なぜ、状態を HTML に置くのでしょうか。最大の理由は、Turbo と噛み合うからです。
第9章で見たとおり、Turbo はページのスナップショットをキャッシュし、復元します。このスナップショットは「そのときの HTML」です。設定値や状態が HTML の data 属性やクラスに表れていれば、それらはスナップショットに含まれ、復元時にもそのまま戻ります。controller は、復元された HTML を見て、connect() で自分を組み立て直せます。
逆に、状態を controller のインスタンス変数だけに持っていると、差し替えや復元のたびに消えます。「さっき開いていたメニューが、戻ったら閉じている」といった不整合が起きます。
HTML 側に情報を置くことは、宣言的で読みやすいだけでなく、Turbo の差し替え・キャッシュに強い、という実利があります。これが、第6部を通じての「状態を HTML に置く」という指針の理由です。
第21章では、Values・Classes・Outlets で、設定値・クラス名・controller 連携を HTML 側に持たせました。次の第22章では、Stimulus から外部の JavaScript ライブラリを安全に扱う方法を学び、第6部を締めます。
参考資料
- Stimulus リファレンス(Values): https://stimulus.hotwired.dev/reference/values
- Stimulus リファレンス(CSS Classes): https://stimulus.hotwired.dev/reference/css-classes
- Stimulus リファレンス(Outlets): https://stimulus.hotwired.dev/reference/outlets
第22章 外部ライブラリと連携する
この章のねらい
実務では、日付ピッカーやチャートなど、外部の JavaScript ライブラリを使いたくなります。Stimulus は、こうしたライブラリを「いつ初期化し、いつ破棄するか」を管理する、ちょうどよい器になります。
ただし、Turbo を使うアプリでは注意が要ります。Turbo は画面を差し替え、キャッシュします。外部ライブラリを素朴に初期化すると、差し替えやキャッシュのたびに二重に初期化されたり、後始末されずに残ったりします。
この章では、外部ライブラリを connect() / disconnect() で安全に扱い、Turbo のキャッシュと共存させる方法を学びます。第6部の締めです。
22.1 connect / disconnect でライフサイクルに合わせる
外部ライブラリ連携の基本は、第19章で見た connect() と disconnect() に、初期化と破棄を合わせることです。
connect()… ライブラリを初期化するdisconnect()… ライブラリを破棄する
connect() だけ書いて disconnect() を書かないと、要素が消えてもライブラリが残り続けます。Turbo は画面を何度も差し替えるので、これが積もると問題になります。初期化したら、必ず破棄する。これが鉄則です。
22.2 動的 DOM と再接続
なぜ破棄がそれほど大事なのでしょうか。第19章で見たとおり、Stimulus の connect() は、Turbo の差し替えのたびに呼ばれます。
たとえば、一覧と詳細を行き来すると、そのたびに詳細の connect() が走ります。connect() でライブラリを初期化していれば、行き来のたびに初期化されます。disconnect() で破棄していないと、前の初期化が残ったまま、新しい初期化が重なります。これが二重初期化です。
Turbo は素の DOM を差し替えます。ライブラリが作った内部状態や、要素の外に追加した DOM(ポップアップなど)は、Turbo は知りません。だから、disconnect() で自分で片付ける必要があります。
22.3 chart / date picker の例
チャートライブラリ(ここでは Chart.js を例にします)を Stimulus で包みます。
app/javascript/controllers/chart_controller.js
import { Controller } from "@hotwired/stimulus"
import Chart from "chart.js/auto"
export default class extends Controller {
connect() {
this.chart = new Chart(this.element, {
type: "bar",
data: { /* ... */ }
})
}
disconnect() {
this.chart.destroy()
}
}
connect() でチャートを生成し、インスタンスを this.chart に持っておきます。disconnect() で、そのインスタンスの destroy() を呼んで破棄します。これで、画面を何度行き来しても、チャートは毎回きれいに作り直され、残骸も残りません。
なお Chart.js は <canvas> 要素に描画します。この controller は <canvas data-controller="chart"> のように、canvas へ結びつける前提です(this.element が canvas になります)。
日付ピッカーも同じ形です。connect() で入力欄にピッカーを初期化し、disconnect() でピッカーを破棄します。ライブラリによっては、ポップアップを <body> 直下に追加するものがあります。その場合、要素を消すだけではポップアップが残るので、disconnect() での破棄がいっそう重要です。
22.4 cleanup
disconnect() で破棄するもの、というのは、具体的には次のようなものです。
- ライブラリのインスタンス(
destroy()などで) - 自分で登録したタイマー(
clearTimeout/clearInterval) - 自分で
windowやdocumentに登録したイベントリスナー(removeEventListener) - 要素の外に追加した DOM
第21章のトーストでも、disconnect() で clearTimeout していました。connect() で何かを始めたら、それを disconnect() で止める、と対にして考えます。
22.5 Turbo cache との相互作用
もう 1 つ、キャッシュ特有の注意があります。第9章で見たとおり、Turbo はページを離れる直前にスナップショットを保存し、戻る・進むでプレビュー表示します。
ここで問題になるのは、スナップショットが保存されるのは、要素が消える前(つまり disconnect() より前)だということです。ライブラリが要素の中の DOM を大きく書き換えていると、その書き換え後の状態がスナップショットに焼き付きます。戻ってきたとき、その焼き付いた DOM の上で connect() がもう一度走り、見た目が壊れることがあります。
これを防ぐには、スナップショット保存の直前に、DOM を初期状態へ戻します。turbo:before-cache イベントで行います。
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.beforeCache = () => this.teardown()
document.addEventListener("turbo:before-cache", this.beforeCache)
this.setup()
}
disconnect() {
document.removeEventListener("turbo:before-cache", this.beforeCache)
this.teardown()
}
setup() { /* ライブラリの初期化 */ }
teardown() { /* ライブラリの破棄と DOM の復元 */ }
}
turbo:before-cache で teardown() を呼び、スナップショットには初期化前のきれいな DOM が入るようにします。これで、プレビュー表示で壊れた見た目が出るのを防げます。第9章で「外部ライブラリは第22章で扱う」と送っていたのが、この後始末です。
22.6 importmap での外部ライブラリ読み込み
最後に、外部ライブラリの読み込みです。第6章で見たとおり、本書は importmap を使います。外部ライブラリは、公開された ES モジュールを pin して読み込みます。
bin/importmap pin chart.js
これで config/importmap.rb に pin が追加され、import Chart from "chart.js/auto" のように読み込めます。ライブラリによっては依存パッケージを伴います(Chart.js も内部で別パッケージに依存します)。bin/importmap pin は依存も含めて pin しようとしますが、実際にどう解決されたかは bin/importmap json で確認しておくと安全です。
ただし、第6章で触れた注意がここでも効きます。importmap が面倒を見るのは JavaScript だけです。ライブラリが必要とする CSS は、別途読み込みます。日付ピッカーのように見た目を持つライブラリでは、CSS の読み込みを忘れると、機能はしても見た目が崩れます。また、ES モジュールとして配信されていないライブラリは、importmap では扱いにくいことがあります。その場合は、第6章で触れた jsbundling 構成が選択肢になります。
第22章で、第6部を締めます。Stimulus を「HTML に振る舞いを足す小さな JavaScript」として学び、controller・action・target、Values・Classes・Outlets、そして外部ライブラリとの安全な連携まで見ました。次の第7部では、ここまでの Turbo と Stimulus を組み合わせて、検索・ページネーション・モーダル・通知といった実務的な UI を作ります。
参考資料
- Stimulus リファレンス(Lifecycle Callbacks): https://stimulus.hotwired.dev/reference/lifecycle-callbacks
- Turbo のイベントリファレンス: https://turbo.hotwired.dev/reference/events
- Rails ガイド「Rails で JavaScript を扱う」: https://guides.rubyonrails.org/working_with_javascript_in_rails.html
ハンズオン(第6部): モーダル、確認 UI、ソート UI を作る
サーバーとのやり取りが要らない振る舞いを、Stimulus で Relay に足します。
この部の到達状態
- ドロップダウンメニューが Stimulus だけで開閉する
- コメント入力欄に文字数カウンタが付く
- 削除前に確認ダイアログが出る
- 自動で消えるトーストの controller が動く(表示秒数は Values で HTML から渡す)
due_onの date picker(外部ライブラリ)が、Turbo 遷移・cache をまたいでも安全に動く
作る・変える
- controller / action / target の基本を、文字数カウンタで覚える
- Values / Classes / Outlets で、トーストの表示秒数・表示切り替え・UI 間連携を実装する
- 外部ライブラリを connect / disconnect で初期化・破棄し、cleanup を入れる
完成条件
- ページ遷移後もドロップダウン・確認・カウンタが動く
- Turbo で画面が差し替わっても二重初期化やメモリリークが起きない
Relay の現在地
サーバー不要の振る舞いを Stimulus で安全に足せる状態。 次の第7部で、ここまでの Turbo と Stimulus を組み合わせて実務 UI に仕上げます。
第7部 実務で使う Hotwire UI パターン
検索、ページネーション、フォーム UX、モーダル、通知といった実務で頻出する UI を題材に、Turbo Frames / Turbo Streams / Stimulus の使い分けを体得します。各章では「サーバーの状態が要るか」「更新は 1 か所か複数か」「きっかけは誰か」を問い直します。
各章で扱う a11y とテストは、その UI を作るうえで最低限その場で確認する範囲に留めます。横断的なテスト戦略と観察ツールの体系化は第8部で行います。第7部は「作る」、第8部は「束ねて守る」と切り分けて読んでください。
第23章 検索と絞り込み
この章のねらい
第7部では、ここまでの Turbo と Stimulus を組み合わせて、実務でよく出る UI を作ります。最初は検索と絞り込みです。
この章で作るのは、入力に追従して一覧だけが絞り込まれ、しかも結果を URL で共有・リロードできる検索です。Turbo Frames が「1 か所だけを差し替える」代表例だと体得します。
この部では、各章で次の 3 つの問いを使い回します。サーバーの状態が要るか/更新は 1 か所か複数か/きっかけは誰か。検索は「サーバーの状態が要る・更新は一覧 1 か所・きっかけは自分」なので、Turbo Frames が向きます。
23.1 完成イメージ
タスク一覧の上部に、検索ボックスとステータスの絞り込みを置きます。文字を入力すると、少し待って一覧だけが絞り込まれます。/tasks?q=...&status=... のように URL にも条件が反映され、その URL を共有・リロードしても同じ結果が出ます。
23.2 この章の選択
更新したいのは一覧の 1 か所だけです。だから Turbo Frames を使います。検索フォームは状態を変えない読み取りなので、GET です(第8章)。入力への追従は、サーバー往復の要らない部分なので Stimulus が担います。
23.3 通常の GET 検索を作る
まず、Hotwire を使わない普通の検索から始めます。controller で、パラメータに応じて絞り込みます。
app/controllers/tasks_controller.rb(index)
def index
@tasks = Task.all
if params[:q].present?
@tasks = @tasks.where("title LIKE ?", "%#{Task.sanitize_sql_like(params[:q])}%")
end
@tasks = @tasks.where(status: params[:status]) if params[:status].present?
end
LIKE の値は ? でバインドしているので、SQL インジェクションは防げます。さらに Task.sanitize_sql_like を通して、ユーザー入力に含まれる % や _ を、ワイルドカードではなくただの文字として扱えるようにしています。
検索フォームは、tasks_path への GET です。
app/views/tasks/index.html.erb(抜粋)
<%= form_with url: tasks_path, method: :get do |form| %>
<%= form.search_field :q, value: params[:q] %>
<%= form.select :status, Task.statuses.keys, { include_blank: "すべて", selected: params[:status] } %>
<%= form.submit "検索" %>
<% end %>
<%= render "tasks", tasks: @tasks %>
この時点では、検索するとページ全体が再描画されます(Turbo Drive 経由なので白い画面は出ませんが、ページ全体の visit です)。
23.4 一覧を frame で囲む
一覧だけを差し替えるために、結果を frame で囲みます。
app/views/tasks/index.html.erb(抜粋)
<%= form_with url: tasks_path, method: :get, data: { turbo_frame: "task_list" } do |form| %>
<%= form.search_field :q, value: params[:q] %>
<%= form.select :status, Task.statuses.keys, { include_blank: "すべて", selected: params[:status] } %>
<%= form.submit "検索" %>
<% end %>
<%= turbo_frame_tag "task_list" do %>
<%= render "tasks", tasks: @tasks %>
<% end %>
フォームに data-turbo-frame="task_list" を付けました。これで、検索の GET は id="task_list" の frame だけを差し替えます(第11章)。index のレスポンスにも同じ frame があるので、結果の部分だけが入れ替わります。検索ボックスやヘッダーは動きません。
23.5 Stimulus で requestSubmit を debounce する
「検索」ボタンを押さなくても、入力に追従して絞り込みたいところです。Stimulus で、入力のたびにフォームを送信します。ただし、1 文字ごとに送るとリクエストが多すぎるので、少し待ってから送る(debounce)ようにします。
app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
submit() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
}
フォームに controller と action を付けます。
<%= form_with url: tasks_path, method: :get,
data: { turbo_frame: "task_list", controller: "search",
action: "input->search#submit change->search#submit" } do |form| %>
action は form 要素に付けているので、子要素から伝わってくるイベントを拾います。ここで、要素によって発火するイベントが違う点に注意します。検索ボックスのテキスト入力は input イベント、ステータスの <select> は change イベントで変化します。どちらも拾えるように、input と change の 2 つを指定しています。どちらが起きても submit が呼ばれ、300 ミリ秒待ってから this.element.requestSubmit() でフォームを送信します。requestSubmit() は、ボタンを押したのと同じようにフォームを送り、Turbo がそれを横取りして frame を差し替えます。待ち時間は Values で持たせているので(第21章)、HTML 側から変えられます。
23.6 data-turbo-action="advance" で履歴に積む
ここまでで一覧は絞り込めますが、1 つ問題があります。frame の差し替えでは URL が変わりません(第11章)。検索結果を共有・リロードできるようにするには、URL に条件を反映させる必要があります。
frame に data-turbo-action="advance" を付けます。
<%= turbo_frame_tag "task_list", data: { turbo_action: "advance" } do %>
<%= render "tasks", tasks: @tasks %>
<% end %>
advance を付けると、frame の差し替えに合わせてブラウザの URL も更新されます。/tasks?q=bug&status=todo のような URL になり、共有・リロード・戻る操作に耐えます。検索条件が URL に残るので、誰かに送れば同じ結果が再現できます。
23.7 フレーム外を更新したくなったときの判断
「検索結果の件数も出したい」となったとき、件数バッジを frame の外に置くと、frame の差し替えでは更新できません(第14章)。一覧という 1 か所だけを差し替えているからです。
選択肢は 2 つです。
- 件数バッジを
task_listframe の中に置く。こうすれば、結果と一緒に更新されます。検索の範囲では、これがいちばん素直です。 - どうしても frame の外(離れた場所)に件数を置きたいなら、Turbo Streams へ切り替えます(第5部)。複数箇所の同時更新は Streams の領分です。
まずは frame の中に収める。収まらなくなったら Streams を検討する。この判断軸(第14章)が、ここでも効きます。
23.8 a11y
検索は、目で見ているユーザーには自然ですが、配慮が要ります。
- 結果の frame に
aria-live="polite"を付け、絞り込みの結果が読み上げられるようにします。件数(「3 件見つかりました」など)を結果の中に出すと、より親切です。 - 入力に追従して送信するので、フォーカスは検索ボックスに留めます。
requestSubmit()はフォーカスを動かさないので、入力を続けられます。
<%= turbo_frame_tag "task_list", data: { turbo_action: "advance" }, aria: { live: "polite" } do %>
<p><%= @tasks.size %> 件</p>
<%= render "tasks", tasks: @tasks %>
<% end %>
23.9 テスト
検索は、System Test と Request Test で役割を分けて確かめます。
- System Test … 入力すると一覧が絞り込まれる、という「画面の振る舞い」を確かめます。Stimulus の debounce や frame の差し替えを含みます。
- Request Test … パラメータに対して、controller が正しい結果を返すという「サーバーの絞り込み」を確かめます。
get tasks_path(q: "bug")で、期待する件数・内容になるかを見ます。
System Test だけだと、絞り込み条件の網羅が重くなります。条件の組み合わせは Request Test で、画面の追従は System Test で、と分けると、軽く確実にテストできます。
23.10 アンチパターン
- debounce なし。1 文字ごとに送ると、リクエストが大量に飛びます。必ず待ちを入れます。
- URL に条件を残さない。frame の差し替えだけで
advanceを付けないと、共有もリロードもできません。 - 単一箇所なのに Streams で全置換。一覧 1 か所の更新なら frame で十分です。なんでも Streams にすると、かえって複雑になります。
第23章では、検索を Turbo Frames+Stimulus で作り、URL に条件を残しました。次の第24章では、件数の多い一覧を扱うページネーションと無限スクロールを学びます。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
- Stimulus リファレンス(Actions / Values): https://stimulus.hotwired.dev/reference/actions
- MDN: ARIA live regions: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
第24章 ページネーションと無限スクロール
この章のねらい
タスクが増えると、一覧を一度に全部出すわけにはいきません。この章では、件数の多い一覧を分割して見せる方法を、3 段階で学びます。
通常のページネーション、「もっと読む」ボタンによる追加読み込み、そして無限スクロールです。それぞれに向き不向きがあり、どの Hotwire の道具を使うかも変わります。
24.1 完成イメージ
- ページ送り …
?page=2のようにページを切り替える、いちばん基本的な形 - もっと読む … ボタンを押すと、続きが一覧の末尾に追加される形
- 無限スクロール … 一覧の末尾までスクロールすると、自動で続きが読み込まれる形
下に行くほど「途切れず読める」一方で、操作性やアクセシビリティの注意が増えます。
24.2 この章の選択
3 段階で、使う道具が変わります。
- ページ送りは、一覧を別ページの内容で置換します。Turbo Frames が向きます。
- もっと読むは、続きを末尾に追記します。Turbo Streams の
appendが向きます。 - 無限スクロールは、「末尾が見えた」ことを検知して、もっと読むを自動で押します。検知は Stimulus が担います。
置換は Frames、追記は Streams、検知は Stimulus。第7部の判断軸が、ここでも効きます。
24.3 通常のページネーションを作る
まず、サーバー側でページごとに区切ります。ここでは仕組みを示すため手で書きますが、実務では Pagy や Kaminari などの gem を使うのが普通です。
app/controllers/tasks_controller.rb(index。PER_PAGE はコントローラのクラス定数として定義しておきます)
PER_PAGE = 20
def index
@page = [params[:page].to_i, 1].max
scope = Task.order(:id)
@tasks = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
@next_page = @page + 1 if scope.count > @page * PER_PAGE
end
@next_page は、次のページがあるときだけ値が入ります。最終ページでは nil です。
ビューでは、一覧と「次へ」のリンクを出します。
<div id="tasks">
<%= render @tasks %>
</div>
<% if @next_page %>
<%= link_to "次へ", tasks_path(page: @next_page) %>
<% end %>
この時点では、ページを切り替えるとページ全体が visit されます。
24.4 Frame 内ページネーションと data-turbo-action
一覧とページ送りだけを差し替えるために、frame で囲みます。第23章の検索と同じ考え方です。
<%= turbo_frame_tag "task_list", data: { turbo_action: "advance" } do %>
<div id="tasks">
<%= render @tasks %>
</div>
<% if @next_page %>
<%= link_to "次へ", tasks_path(page: @next_page) %>
<% end %>
<% end %>
ページ送りのリンクは frame の中にあるので、クリックすると task_list frame だけが次ページの内容に置き換わります。data-turbo-action="advance" を付けてあるので、URL も ?page=2 に変わり、リロードや共有、戻る操作に耐えます。
これが「置換」型のページネーションです。一覧をまるごと次ページに置き換えます。
24.5 「もっと読む」ボタンで append を使う
次は「追記」型です。ページを置き換えるのではなく、続きを末尾に足します。
「もっと読む」リンクを、Turbo Streams で応答させます。第15章で見たとおり、GET でも data-turbo-stream を付ければ Turbo Streams を受け取れます。
<div id="tasks">
<%= render @tasks %>
</div>
<div id="pagination">
<% if @next_page %>
<%= link_to "もっと読む", tasks_path(page: @next_page), data: { turbo_stream: true } %>
<% end %>
</div>
controller は、turbo_stream 形式に応答します。
app/controllers/tasks_controller.rb(index に追記)
respond_to do |format|
format.html
format.turbo_stream
end
app/views/tasks/index.turbo_stream.erb
<%= turbo_stream.append "tasks", partial: "tasks/task", collection: @tasks %>
<%= turbo_stream.update "pagination" do %>
<% if @next_page %>
<%= link_to "もっと読む", tasks_path(page: @next_page), data: { turbo_stream: true } %>
<% end %>
<% end %>
「もっと読む」を押すと、次ページのタスクが id="tasks" の末尾に append され、ボタン自体は次ページ用のボタンに update されます。最終ページでは @next_page が nil なので、ボタンが消えます。一覧は置き換わらず、下に伸びていきます。
24.6 IntersectionObserver で自動化する
無限スクロールは、この「もっと読む」を自動で押す形です。末尾が画面に見えたことを検知して、ボタンをクリックします。検知には、ブラウザの IntersectionObserver を使います。これを Stimulus controller で包みます。
app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["button"]
connect() {
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.buttonTarget.click()
}
})
}
buttonTargetConnected(button) {
this.observer.observe(button)
}
buttonTargetDisconnected(button) {
this.observer.unobserve(button)
}
disconnect() {
this.observer.disconnect()
}
}
ここがこの章の肝です。「もっと読む」を押すと、24.5 のとおり update "pagination" でボタンが新しい要素に差し替わります。もし connect() で最初のボタンだけを監視していると、差し替え後は削除済みの旧ボタンを見続け、2 回目以降が発火しません。そこで、ターゲットが差し替わるたびに呼ばれる buttonTargetConnected / buttonTargetDisconnected を使い、新しいボタンを監視し直します。最終ページでボタンが消えれば、buttonTargetDisconnected で監視が外れ、自動読み込みも止まります。
<div id="pagination" data-controller="infinite-scroll">
<% if @next_page %>
<%= link_to "もっと読む", tasks_path(page: @next_page),
data: { turbo_stream: true, infinite_scroll_target: "button" } %>
<% end %>
</div>
ここで data-controller の値に注意します。ファイル名は infinite_scroll_controller.js ですが、識別子はアンダースコアがハイフンになり infinite-scroll です(第19章)。data-controller の値は変換されないので、"infinite-scroll" と書きます。一方、ターゲットの data: { infinite_scroll_target: "button" } は、Rails が属性名を data-infinite-scroll-target に変換するので、そのままで infinite-scroll controller のターゲットになります。
「もっと読む」ボタンが画面に見えると、IntersectionObserver が検知し、ボタンを click() します。クリックは 24.5 の append を起こすので、続きが自動で読み込まれます。disconnect() で監視を解除するのを忘れないでください(第22章)。
24.7 ボタンを残す理由
無限スクロールでも、24.6 のように「もっと読む」ボタンを土台に残すことを勧めます。自動読み込みは、その上に乗せる「おまけ」と考えます。
理由はアクセシビリティです。純粋な無限スクロールは、キーボードだけで操作するユーザーや、スクリーンリーダーのユーザーには扱いにくいものです。ボタンがあれば、スクロールに頼らず続きを読めますし、フォーカスも当てられます。自動読み込みは便利ですが、ボタンという確実な手段を奪ってはいけません。
24.8 URL とスクロール位置
追記型・無限スクロールには、URL とスクロール位置の弱点があります。
ページ送り(24.4)は ?page=2 が URL に残るので、リロードや共有で同じ位置に戻れます。一方、もっと読む・無限スクロールは、下に伸ばしているだけなので、リロードすると最初のページに戻ります。また、詳細へ移動してから戻ると、読み込んだ続きやスクロール位置が失われがちです。
「途切れず読める」ことと「URL で位置を再現できる」ことは、トレードオフです。共有・リロードを重視する一覧はページ送りに、流し読みを重視する一覧は追記型に、と使い分けます。
24.9 テスト
ページネーションのテストでは、次の 2 点を特に確かめます。
- 追記に重複がないこと。「もっと読む」を押したとき、すでに表示済みのタスクが二重に出ないか。
- 最終ページの終端。最後まで読み込んだら、「もっと読む」が消えるか。
System Test で、もっと読むを押して件数が増えること、最後にボタンが消えることを確認します。境界(ちょうど割り切れる件数、1 ページに満たない件数)も見ておくと安心です。
24.10 アンチパターン
- フッターに永遠に到達できない。無限スクロールで、ページ下部のフッターやリンクに、いつまでもたどり着けなくなる。
- 戻ると位置が失われる。詳細から戻ったとき、読み込んだ続きやスクロール位置が消える。
- 監視の解除漏れ。
IntersectionObserverをdisconnect()で解除せず、二重に読み込む。
第24章では、ページネーションを置換・追記・検知の 3 段階で作り分けました。次の第25章では、フォームのバリデーションエラーと、その UX を扱います。第8章で予告した 422 の契約を、ここで本格的に使います。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
- Stimulus(Building Something Real): https://stimulus.hotwired.dev/handbook/building-something-real
- MDN: IntersectionObserver: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
第25章 バリデーションエラーとフォーム UX
この章のねらい
フォームは、Hotwire でいちばんつまずきやすいところです。第8章で「成功は redirect、失敗は 422 でフォーム再描画」という契約を学びました。この章では、その契約を土台に、フォームの体験(UX)とアクセシビリティを仕上げます。
エラーをその場に出す、送信中を示す、二重送信を防ぐ、そしてスクリーンリーダーにエラーを正しく伝える。実務のフォームに必要な要素を、Relay のタスク作成・編集で組み立てます。
25.1 完成イメージ
入力が不正なら、ページ遷移せずに、フォームのその場にエラーが表示されます。フォーカスはエラーの位置に移り、スクリーンリーダーにもエラーが伝わります。成功すれば、一覧とフラッシュが更新されます。送信中はボタンが押せなくなり、二重送信が防がれます。
25.2 この章の選択
主役はサーバー側のバリデーションです。Rails のモデルが検証し、失敗を 422 で返します。Stimulus は、あくまで補助です。送信中の表示や文字数カウンタなど、体験をよくする役割に徹し、サーバー検証の代わりにはしません。
25.3 失敗時に 422 を返す
第8章・第16章で見たとおり、保存に失敗したら 422 を返します。コードでは status: :unprocessable_entity です。
if @task.save
# 成功時の応答(25.5)
else
# 失敗時: フォームを 422 で返す
end
Turbo は、422 で返ってきた HTML を画面に反映します。成功時にうっかり 200 で render すると、Turbo は再描画せず送信元の URL に留まります(第8章)。失敗は必ず 422、と徹底します。
25.4 フォームを同じ場所に戻す
失敗時は、入力中のフォームを、エラー付きで同じ場所に戻します。やり方は、これまでに学んだ 2 つのどちらかです。
- インライン編集のように frame の中で完結しているなら、frame に 422 のフォームを返します(第12章)。
- 一覧ページの inline フォームなら、Turbo Streams でフォーム領域を差し替えます(第16章)。
どちらも、ページ遷移せずに、エラーの付いたフォームがその場に現れ、ユーザーは入力をやり直せます。
25.5 成功時に一覧と flash を更新する
成功時は、第16章・第17章の Turbo Streams で、複数箇所を同時に更新します。
<%= turbo_stream.prepend "tasks", @task %>
<%= turbo_stream.update "new_task_form" do %>
<%= render "form", task: Task.new %>
<% end %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
新しいタスクを一覧に追加し、フォームを空に戻し、フラッシュを出します。フォームの成功・失敗が、どちらもページ遷移なしで完結します。
25.6 Stimulus で補助する
ここから、体験をよくする補助です。
送信中の表示と二重送信の防止は、実は Turbo に組み込みの助けがあります。Turbo はフォーム送信中、送信ボタンを自動で無効にします。さらに、ボタンに data-turbo-submits-with を付けると、送信中だけ文言を差し替えられます。
<%= form.submit "作成", data: { turbo_submits_with: "作成中…" } %>
これだけで、送信中はボタンが「作成中…」になり、押せなくなります。二重送信の多くは、これで防げます。
文字数カウンタのような入力中の補助は、第20章で作った Stimulus の controller を使います。これらは入力体験を助けるもので、サーバー検証とは別物です。
25.7 a11y
フォームのアクセシビリティは、この章の主戦場です。エラーを「見える」だけでなく「伝わる」ようにします。
- エラーサマリ。フォームの先頭に、エラーの一覧を出します。
role="alert"を付けると、表示時に読み上げられます。さらに、再描画後にここへフォーカスを移すと、ユーザーはすぐエラーに気づけます。フォーカス移動は、第19章で作ったautofocuscontroller(connectでthis.element.focus()する)を再利用します。connectは frame の差し替えでも Turbo Streams の差し込みでも呼ばれるので、どちらの経路で 422 が返っても確実にフォーカスが移ります(HTML のautofocus属性は frame/ページの描画後には効きますが、stream で差し込んだ要素には効かないため、connect方式に寄せます)。 - 各フィールドの紐づけ。エラーのあるフィールドに
aria-invalid="true"を付け、aria-describedbyでそのフィールドのエラーメッセージと結びつけます。aria-invalidは、エラーがないときは"false"(妥当である、という意味)になります。これは仕様どおりで問題ありません。
<% if task.errors.any? %>
<div role="alert" tabindex="-1" data-controller="autofocus">
<%= pluralize(task.errors.count, "件") %>のエラーがあります。
</div>
<% end %>
<%= form.text_field :title,
"aria-invalid": task.errors[:title].any?,
"aria-describedby": ("title_error" if task.errors[:title].any?) %>
<% if task.errors[:title].any? %>
<span id="title_error"><%= task.errors[:title].to_sentence %></span>
<% end %>
目で見るユーザーには色や位置でエラーが分かりますが、スクリーンリーダーのユーザーには、こうした属性がないと伝わりません。
25.8 URL は基本不変にする
フォームのやり直しでは、URL は変えません。失敗しても成功しても、ユーザーは同じ画面で操作を続けます。検索(第23章)のように URL に状態を残す必要はありません。advance のような URL 操作は、フォームには使わないのが基本です。
25.9 テスト
フォームは、次の 3 つを確かめます。
- 無効な入力で、エラーが同じ画面に出る(ページ遷移しない)
- 有効な入力で、成功し、一覧が更新される
- 二重送信が防がれる(送信中にボタンが無効になる)
System Test で、無効入力時にフォームが残りエラーが出ること、有効入力時に一覧へ追加されることを確認します。第16章で書いたテストに、エラー表示の確認(role="alert" の存在など)を足すとよいでしょう。
25.10 アンチパターン
- 失敗時に 422 ではなく 200 で render する。Turbo が再描画せず、エラーが画面に出ません(第8章)。最頻出のつまずきです。
- JavaScript の検証だけにする。クライアント側の検証は補助です。サーバー検証を省くと、簡単にすり抜けられます。
- エラー時にフォーカスが迷子になる。再描画後、フォーカスがどこにあるか分からないと、キーボードやスクリーンリーダーのユーザーが迷います。エラーサマリへフォーカスを移します。
第25章では、フォームのエラー表示・送信中・二重送信・a11y を仕上げました。次の第26章では、モーダル・タブ・ドロップダウンを題材に、「サーバーの状態が要るか」で実装が分かれることを学びます。
参考資料
- Turbo Drive(Handbook、フォーム送信): https://turbo.hotwired.dev/handbook/drive
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
- MDN: ARIA(aria-invalid / aria-describedby / role=alert): https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA
第26章 モーダル、タブ、ドロップダウン
この章のねらい
モーダル、タブ、ドロップダウン。どれも見慣れた UI ですが、Hotwire では「何で作るか」が一つに決まりません。サーバーの内容が要るかどうかで、実装が分かれるからです。
この章では、その仕分けを学びます。サーバーの内容が要らないものは Stimulus だけで、サーバーから内容を取るものは Turbo Frames や Turbo Streams で作ります。
26.1 完成イメージ
- ドロップダウン … ボタンを押すとメニューが開く。中身は最初からある
- タブ … 切り替えで表示が変わる。中身が最初からある「静的タブ」と、開いたときにサーバーから取る「遅延タブ」がある
- モーダル … 「新規作成」を押すと、サーバーからフォームを取って重ねて表示する
26.2 この章の選択
判断の軸は「サーバーの状態(内容)が要るか」です。
- ドロップダウンや静的タブは、中身が最初からページにあります。開閉や切り替えはサーバー往復が要りません。だから Stimulus だけで作ります。
- 遅延タブやモーダルは、中身をサーバーから取ります。だから Turbo Frames や Turbo Streams を使います。
同じ「重ねて表示する」見た目でも、中身がどこから来るかで道具が変わります。
26.3 ドロップダウンを Stimulus だけで作る
ドロップダウンは、サーバーと関係ありません。Stimulus だけで作ります。
app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "button"]
toggle() {
const willOpen = this.menuTarget.hidden
this.menuTarget.hidden = !willOpen
this.buttonTarget.setAttribute("aria-expanded", String(willOpen))
}
close(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.hidden = true
this.buttonTarget.setAttribute("aria-expanded", "false")
}
}
}
<div data-controller="dropdown" data-action="click@window->dropdown#close">
<button data-dropdown-target="button" data-action="dropdown#toggle"
aria-haspopup="true" aria-expanded="false">メニュー</button>
<ul data-dropdown-target="menu" hidden>
<li>...</li>
</ul>
</div>
toggle でメニューの表示を切り替え、あわせて開閉状態を aria-expanded に反映します。click@window->dropdown#close で、外側をクリックしたときに閉じます(@window は window で起きたイベントを拾う書き方です)。サーバーへの問い合わせは一切ありません。
26.4 静的タブを Stimulus だけで作る
中身が最初からページにあるタブも、Stimulus だけで作れます。選ばれたタブのパネルを表示し、ほかを隠すだけです。
このとき、アクセシビリティのために、タブには適切な役割(role)を付けます。tablist・tab・tabpanel です。これは 26.8 でまとめて扱います。中身がすでにあるので、切り替えはサーバーと無関係で、Stimulus が表示を出し入れします。
26.5 遅延タブを Turbo Frames で読み込む
タブの中身が重い、あるいは最初は要らない場合は、開いたときにサーバーから取ります。これは第13章で見た遅延読み込みです。
タブのリンクで、共通の content frame を差し替えます。
<nav role="tablist">
<%= link_to "概要", overview_project_path(@project), role: "tab", data: { turbo_frame: "tab_content" } %>
<%= link_to "タスク", tasklist_project_path(@project), role: "tab", data: { turbo_frame: "tab_content" } %>
</nav>
<%= turbo_frame_tag "tab_content" do %>
<p>タブを選んでください。</p>
<% end %>
各タブの行き先が id="tab_content" の frame を返します(第13章)。中身をサーバーから取るので、Stimulus 単独ではなく Turbo Frames です。
26.6 モーダルを Turbo Frames と <dialog> で作る
モーダルは、サーバーからフォームを取って重ねます。Turbo Frames で内容を取り、<dialog> 要素で重ねて表示します。
まず、レイアウトに空のモーダル用 frame を置きます。
<%= turbo_frame_tag "modal" %>
「新規作成」リンクで、この frame を差し替えます。
<%= link_to "新規作成", new_task_path, data: { turbo_frame: "modal" } %>
new.html.erb は、id="modal" の frame の中に <dialog> を置き、Stimulus で開きます。
app/views/tasks/new.html.erb
<%= turbo_frame_tag "modal" do %>
<dialog data-controller="modal" data-action="close->modal#cleanup">
<%= render "form", task: @task %>
</dialog>
<% end %>
app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.showModal()
}
cleanup() {
this.element.remove()
}
}
connect() で showModal() を呼ぶと、<dialog> がモーダルとして開きます。「新規作成」を押すと、new_task_path の内容が modal frame に入り、connect() が走ってモーダルが開きます。data-action="close->modal#cleanup" は、Esc などで <dialog> が閉じたとき(close イベント)に cleanup を呼び、閉じたダイアログ要素を DOM から取り除きます。残骸を残さないための後始末です。
26.7 成功時に Streams でモーダルを空にする
モーダルの中のフォームを送信して成功したら、モーダルを閉じ、一覧を更新します。これは複数箇所の更新なので、Turbo Streams です(第16章)。
app/views/tasks/create.turbo_stream.erb(成功時)
<%= turbo_stream.prepend "tasks", @task %>
<%= turbo_stream.update "modal" %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
turbo_stream.update "modal"(中身なし)で、モーダル frame を空にします。中の <dialog> ごと消えるので、モーダルが閉じます。同時に、一覧の先頭へタスクを追加し、フラッシュを出します。失敗時は、第25章のとおり 422 でフォームを差し替え、モーダルは開いたままにします。
26.8 a11y
モーダルとタブは、アクセシビリティの作り込みが要ります。
- モーダル。
<dialog>をshowModal()で開くと、フォーカスがダイアログ内に閉じ込められ(focus trap)、Escで閉じられ、開閉に伴うフォーカスの移動も、ブラウザがかなり面倒を見てくれます。これが、<div>で自作せず<dialog>を使う理由です。自作すると、focus trap も Esc も自分で実装することになり、抜けが出ます。 - タブ。
role="tablist"・role="tab"・role="tabpanel"を付け、選択中のタブをaria-selected="true"にします。矢印キーでの移動なども、タブの標準的な振る舞いです。 - ドロップダウン。開閉ボタンに
aria-expandedを付け、Escで閉じ、キーボードで項目をたどれるようにします。
26.9 URL: モーダルをディープリンクにするか
モーダルには、URL の設計判断があります。/tasks/new を開いた状態を URL に残す(ディープリンク)か、残さないかです。
残さない場合は、26.6 のように frame の差し替えだけで開きます(URL は変わりません)。手軽ですが、モーダルを開いた URL を共有・リロードで再現できません。残したい場合は、第14章の data-turbo-action="advance" を使い、モーダルを開く遷移を URL に積みます。
多くのモーダル(作成・編集など)は、ディープリンクが不要です。まずは URL を変えない素朴な形から始め、共有が必要になったら advance を検討します。
26.10 テスト
モーダルは、System Test で次を確かめます。
- 「新規作成」で開く
Escで閉じる- 送信に成功すると、モーダルが閉じ、一覧にタスクが追加される
ドロップダウンやタブは、開閉・切り替えと、開いていないときに中身が見えないことを確認します。
26.11 アンチパターン
- モーダルの乱用。何でもモーダルにすると、URL が機能しなくなり、戻る操作も効かなくなります。
<div>で自作してキーボード操作が壊れる。focus trap や Esc を自前で作ると、抜けが出ます。<dialog>を使います。- Stimulus でサーバー内容を二重管理する。サーバーから取れる内容を、Stimulus 側でも持とうとすると、ずれます。サーバー内容が要るなら Frames / Streams に任せます。
第26章では、サーバー内容の要否で UI の作り方を仕分けました。次の第27章では、通知・トースト・フラッシュを題材に、Turbo Streams・Stimulus・Action Cable の合わせ技で第7部を締めます。
参考資料
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
- Stimulus リファレンス(Actions): https://stimulus.hotwired.dev/reference/actions
- MDN: dialog 要素: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
- ARIA Authoring Practices(Tabs / Menu / Dialog): https://www.w3.org/WAI/ARIA/apg/patterns/
第27章 通知、トースト、フラッシュメッセージ
この章のねらい
第7部の締めは、通知です。操作の結果を伝えるフラッシュ、少し経つと消えるトースト、そして他のユーザーの操作を知らせるリアルタイム通知。
この章は、これまで学んだものの合わせ技です。Turbo Streams で差し込み、Stimulus で演出し、Action Cable で配信します。第5部・第6部・第18章が、ここで一つにつながります。
27.1 完成イメージ
操作するとトーストが画面の隅に出て、数秒後に自動で消えます。閉じるボタンでも消せます。複数の通知は積み重なります。さらに、他のユーザーが自分にタスクを割り当てると、その通知がリアルタイムで届きます。
27.2 この章の選択
役割を 3 つに分けます。
- 差し込みは Turbo Streams。通知を画面に足すのは、サーバーからの命令です(第5部)。
- 演出は Stimulus。自動で消す、閉じる、フェードする、はサーバー往復の要らない振る舞いです(第6部)。
- 他者発の配信は Action Cable。他のユーザーの操作をきっかけに届けるのは broadcast です(第18章)。
27.3 通常の flash を整理する
まず、フラッシュの置き場所を整えます。レイアウトに、フラッシュ用のコンテナを置きます。
app/views/layouts/_flash.html.erb
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>"><%= message %></div>
<% end %>
app/views/layouts/application.html.erb(抜粋)
<div id="flash" role="status" aria-live="polite">
<%= render "layouts/flash" %>
</div>
id="flash" で差し替えの宛先にし、role="status" / aria-live="polite" で読み上げ対象にします(第17章)。
27.4 Turbo Streams で flash 領域を更新する
ページ遷移しない操作では、第16章のとおり、Turbo Streams でこの #flash を更新します。
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
controller では flash.now を使います(ページ遷移しないため、第16章)。これで、操作のたびにフラッシュがその場に出ます。
27.5 Stimulus で自動消滅・閉じる・transition
フラッシュを「少し経つと消えるトースト」にするのは、Stimulus の役割です。第21章で作ったトーストの controller を、閉じるボタンに対応させて使います。
app/javascript/controllers/toast_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 5000 } }
connect() {
this.timeout = setTimeout(() => this.dismiss(), this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
dismiss() {
this.element.remove()
}
}
<div data-controller="toast" data-toast-delay-value="5000">
保存しました。
<button type="button" data-action="toast#dismiss" aria-label="閉じる">×</button>
</div>
connect() でタイマーを仕掛け、時間が来たら dismiss() で消します。閉じるボタンからも dismiss() を呼べます。disconnect() でタイマーを片付けるのを忘れないでください(第22章)。消えるときにフェードさせたいなら、dismiss() で CSS のクラスを付け、transitionend を待ってから remove() する形にします。
27.6 複数通知のスタック管理
通知は、続けて起きると複数出ます。重ならないよう、積み重ねるコンテナを用意します。
app/views/layouts/application.html.erb(抜粋)
<div id="toasts" role="status" aria-live="polite"></div>
通知を出すときは、このコンテナにトーストを append します。
<%= turbo_stream.append "toasts", partial: "toasts/toast", locals: { message: "保存しました。" } %>
それぞれのトーストが自分の toast controller を持つので、各自が独立して時間で消えます。#toasts を aria-live 領域にしておけば、追加されたトーストが読み上げられます。
27.7 Action Cable でリアルタイム通知に拡張する
ここまでは「自分の操作」への通知でした。最後に、「他のユーザーの操作」への通知を足します。第18章の broadcast を使います。
各ユーザーのページが、自分宛ての通知を購読します。レイアウトに置きますが、未ログインのページでは current_user が nil になるので、ログイン時だけ描きます。
<% if current_user %>
<%= turbo_stream_from current_user %>
<% end %>
そして、たとえばタスクが誰かに割り当てられたとき、その担当者へトーストを配信します。担当者は未割り当て(nil)のこともあるので、いるときだけ配信します。
if task.assignee
Turbo::StreamsChannel.broadcast_append_to(
task.assignee,
target: "toasts",
partial: "toasts/toast",
locals: { message: "新しいタスクが割り当てられました。" }
)
end
broadcast_append_to で、担当者の toasts コンテナにトーストを追加します。これは、自分の操作で append するのと同じ仕組みで、経路が Action Cable になっただけです(第18章)。担当者の画面に、リアルタイムで通知が現れます。
27.8 a11y
通知は、見えるだけでなく伝わる必要があります。
- 通知のコンテナに
role="status"/aria-live="polite"を付け、追加された通知が読み上げられるようにします。緊急性の高いものはaria-live="assertive"を検討しますが、多用は禁物です。 - 自動で消える情報を、重要情報の唯一の伝達手段にしない。トーストは数秒で消えます。読み上げが追いつかないこともあります。「保存に失敗した」のような重要な情報は、トーストだけに頼らず、フォームのエラー(第25章)など消えない場所にも示します。
27.9 テスト
通知は、System Test で次を確かめます。
- 操作するとトーストが表示される
- 時間が経つ、または閉じるボタンで消える
- 続けて操作すると、複数のトーストが積み重なる
時間で消える挙動は、待ち時間を Values で短くしてテストするか、閉じるボタンでの消去を確認すると安定します。
27.10 アンチパターン
- 重要情報をトーストだけに出す。消えると分からなくなります。消えない場所にも示します。
aria-liveがない。スクリーンリーダーに通知が届きません。- 無限に積もる。古いトーストを消さないと、画面が埋まります。自動消滅と、必要なら上限を設けます。
- 見せてはいけない情報を broadcast する。配信先を誤ると、他人に通知が届きます。配信範囲と認可(第18章・第31章)に注意します。
第27章で、第7部を締めます。検索・ページネーション・フォーム UX・モーダル・通知という実務的な UI を、Turbo と Stimulus の組み合わせで作りました。Relay は実務水準の管理画面になりました。次の第8部では、こうして育てた Relay を、テスト・デバッグ・性能・セキュリティの面から保守します。
参考資料
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
- Rails ガイド「Action Cable の概要」: https://guides.rubyonrails.org/action_cable_overview.html
- Stimulus リファレンス(Values / Lifecycle): https://stimulus.hotwired.dev/reference/values
- MDN: ARIA live regions: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
ハンズオン(第7部): 実務的な管理画面に仕上げる
第23〜27章の UI パターンを Relay に順に適用し、実務水準の管理画面に仕上げます。
この部の到達状態
- URL に連動した検索・絞り込みができる
- ページネーション、「もっと読む」、無限スクロールが選べる
- フォーム UX が a11y 込みで整う(422 再描画、フォーカス移動、aria 属性)
- 新規作成モーダル、タブ、ドロップダウンが動く
- 通知トーストが出る(他ユーザーの操作によるリアルタイム通知を含む)
作る・変える
第23章(検索)→ 第24章(ページネーション)→ 第25章(フォーム UX)→ 第26章(モーダル等)→ 第27章(通知)の順に、Relay へ適用します。
完成条件
- 検索条件が URL で共有・リロードできる
- 第7部 a11y チェックリスト(フォーカス・
aria-live・キーボード操作)を満たす
Relay の現在地
Relay が実務水準の管理画面になった状態。 以降は保守(第8部)とモバイル展開(第9部)です。
第8部 Hotwire アプリを保守する
動いた後の Relay を「見る(テスト)→ 切り分ける(デバッグ)→ 測る(性能)→ 守る(認可)」の順で支えます。第7部までで各章に仕込んだ確認を、ここでテスト戦略・観察ツール・N+1 対策・認可として束ねます。
共通する教訓は、部分更新や broadcast を足しても、テスト・N+1・認可の責任は通常の Rails と変わらないということです。Hotwire は、遅い Rails も危ない Rails も隠してくれません。
第28章 Hotwire のテスト
この章のねらい
第7部までで、Relay は実務水準の管理画面になりました。第8部では、動いた後のアプリを保守します。最初はテストです。
この章のねらいは、テストの戦略を立てることです。何を System Test で守り、何をモデルやリクエストのテストに委ねるか。その配分を決め、第3〜7部で各章に書いてきた小さなテストを、その中に位置づけます。
この部を貫く軸は「Hotwire は、遅い Rails も危ない Rails も隠してくれない」です。Hotwire は Rails の上の薄い層なので、テストも「Rails のテスト+Hotwire 固有の観察点」になります。
28.1 なぜ System Test が重要か
Hotwire の動きは、部分更新の積み重ねです。フォームを送ると一覧が prepend され、件数が変わり、フラッシュが出る。これらは、ブラウザの上で JavaScript(Turbo・Stimulus)が動いて初めて成立します。
サーバー側のテストだけでは、ここを確かめきれません。controller が正しい Turbo Streams を返しても、それがブラウザで正しく適用されるか、Stimulus の controller が結びついて動くか、までは見られないからです。
だから、ブラウザを動かす System Test が要ります。System Test は、実際のブラウザ(ヘッドレスの Chrome など)で画面を操作し、Hotwire の結合部を確かめます。
28.2 テストの配分
とはいえ、何でも System Test で確かめるのは重すぎます。System Test は遅く、壊れやすいからです。テストは、層で分けて配分します。
- モデルのテスト … バリデーション、enum、検索のスコープなど、サーバー側のロジック。速くて確実なので、ここで手厚く。
- リクエストのテスト … controller が返すステータスや形式。422 が返るか(第8章)、
turbo_stream形式で応答するか、検索パラメータで正しく絞り込むか(第23章)。条件の組み合わせは、ここで網羅。 - System Test … 上の積み重ねが、ブラウザで結合して動くか。インライン編集の差し替え、Streams の追加、Stimulus の振る舞いなど、JavaScript が絡む経路に絞る。
第7部までで各章に書いてきた小さなテストは、この配分の中に収まります。「条件は下の層で、結合は System Test で」と分けると、軽く確実なテストになります。
28.3 Turbo Drive のテスト
Turbo Drive の確認は、第8章で書いたフォーム送信のテストが代表です。
- 成功すると詳細へ遷移し、成功メッセージが出る
- 失敗すると、ページ遷移せず、同じ画面にエラーが出る(422)
「ページ遷移したか/しなかったか」が、Turbo Drive のテストの肝です。失敗時にページ遷移していないことを確かめると、422 の契約が効いていると分かります。
28.4 Frames のテスト
Turbo Frames の確認は、第12章のインライン編集が代表です。
within "##{dom_id(task)}" で frame の中に操作を絞り、編集して保存し、その frame が表示に戻ったことを確かめます。このとき、編集フォームが消えたこと(assert_no_field)まで見るのが大事でした(第12章)。frame の中だけが差し替わり、ほかが動いていないことが、Frames のテストの肝です。
28.5 Streams のテスト
Turbo Streams の確認は、第16章・第17章が代表です。
- 作成すると、一覧の先頭にタスクが追加される(ページ遷移なし)
- 削除すると、一覧から消える
- 件数や空状態が、同時に更新される
within "#tasks" で一覧の中を確かめ、assert_no_text で削除を確かめます。複数箇所が同時に変わることを、それぞれの領域で確認します。
28.6 Stimulus の振る舞いを確認する
Stimulus の確認は、第20章の文字数カウンタが代表です。入力すると、文字数の表示が変わる。これはブラウザで JavaScript が動かないと起きないので、System Test で確かめます。
fill_in で入力し、assert_selector で表示が変わったことを見ます。Stimulus が要素に結びついて動いていることが、これで分かります。
28.7 非同期更新を待つ
System Test でいちばん大事なのが、非同期更新の待ち方です。Hotwire の更新は、リクエストの往復を挟むので、操作した瞬間には終わっていません。
ここで sleep を使ってはいけません。待ち時間は環境で変わり、長すぎれば遅く、短すぎれば失敗するからです。
代わりに、Capybara の待つマッチャを使います。assert_selector や assert_text、assert_no_text は、条件が満たされるまで(既定の待ち時間まで)自動で待ち、再試行します。
click_on "Create Task"
within "#tasks" do
assert_text "新しいタスク" # 追加されるまで自動で待つ
end
assert_text が、タスクが追加されるまで待ってくれます。削除の確認も、assert_no_text が「消えるまで待つ」ので、sleep は要りません。
28.8 壊れやすいテストを避ける
最後に、フレーク(不安定なテスト)を避ける勘所です。
- 更新の前に assert しない。操作の直後、待つマッチャを使わずに値を読むと、まだ更新前で偶然通ったり落ちたりします。必ず、待つマッチャ(
assert_selectorなど)で「変わったこと」を確かめます。 - broadcast は同期して確かめる。Action Cable のリアルタイム更新(第18章)は、配信が非同期になりがちで、System Test では特に不安定です。配信の中身(どんな Turbo Streams を流すか)は、モデルやリクエストのテストで確かめ、System Test では「2 つのセッション(Capybara の
using_session)を開いて、片方の操作がもう片方に反映される」ような結合だけに絞ると、安定します。非同期ジョブ(*_later)を使うなら、テストでジョブを実行させてから確かめます。
第28章では、テストを層で配分し、Hotwire の結合を System Test で守る戦略を立てました。次の第29章では、テストでは捉えきれない不具合を追うための、デバッグとイベント観察の道具を整えます。
参考資料
- Rails ガイド「テスティング」: https://guides.rubyonrails.org/testing.html
- Rails ガイド「システムテスト」: https://guides.rubyonrails.org/testing.html#system-testing
- Turbo Handbook: https://turbo.hotwired.dev/handbook/introduction
第29章 デバッグとイベント観察
この章のねらい
Hotwire のアプリがうまく動かないとき、「どこで差し替えが止まったのか」を切り分けられると、解決が早くなります。この章では、その観察の道具を体系化します。
闇雲にコードを直す前に、観察します。順番は、Network → Turbo のイベント → Stimulus の接続 → Frame / Stream の target → morph、です。上から順に見れば、たいていの不具合は場所を特定できます。第28章のテストでは捉えきれない不具合を、ここで追います。
29.1 Network タブで見るべきもの
最初に見るのは、ブラウザの DevTools の Network タブです。リクエストとレスポンスが、意図どおりかを確かめます。
- リクエストのメソッド。GET か、POST / PATCH / DELETE か。
Acceptヘッダー。Turbo Streams を期待する送信には、text/vnd.turbo-stream.htmlが含まれているか(第15章)。- レスポンスのステータス。成功は redirect(303 など)、失敗は 422 か(第8章)。ここがずれていると、フォームが期待どおり動きません。
- レスポンスの中身。
<turbo-stream>の命令や、<turbo-frame>の HTML が、実際に返ってきているか。
「サーバーは何を返したか」が分かれば、問題がサーバー側かブラウザ側か、切り分けられます。
29.2 Turbo イベントをログに出す
次に、Turbo が何をしたかを見ます。第10章で見たとおり、Turbo のイベントは自分で購読しないと表に出ません。デバッグ時は、主なイベントをログに出します。
;["turbo:visit", "turbo:submit-start", "turbo:submit-end", "turbo:before-render", "turbo:render", "turbo:frame-load"].forEach((name) => {
document.addEventListener(name, (event) => console.log(name, event.detail))
})
これで、visit が始まったか、送信が成功したか、frame が読み込まれたか、といった節目が Console に出ます。frame の描画を細かく見たいときは、turbo:frame-render も加えます。「イベントが出ない=そもそも Turbo が動いていない」と分かります。
29.3 Stimulus controller の接続を確認する
Stimulus が動かないときは、controller が結びついているかを確かめます。第6章で見たとおり、application.debug を true にすると、controller の接続・切断がログに出ます。
// app/javascript/controllers/application.js
application.debug = true
接続のログが出なければ、data-controller の名前とファイル名がずれている(第19章)、ファイルの置き場所が違う、といった原因が疑えます。window.Stimulus から、登録された controller を確認することもできます。
29.4 Frame / Stream の target を確認する
Turbo Frames や Turbo Streams で「更新されない」ときは、たいてい id の不一致です(第11章・第17章)。
- Frame なら、リンク先のレスポンスに、同じ
idの<turbo-frame>があるか。なければ、frame に案内メッセージが出て例外になります(第11章)。 - Stream なら、
targetが指すidの要素が、画面に存在するか。存在しないidを指すと、その命令は静かに何も起こしません。
DevTools の Elements タブで、<turbo-frame> の id や、stream の target が指す要素を探します。dom_id を使っていれば、表示側と命令側の id は揃うはずです(第17章)。手書きの id がずれていないかを疑います。
29.5 morphing の差分を疑う
morph(第9章)を使っているのに、思ったとおりに更新されない・要素が消えない・状態が残る、というときは、morph の差分を疑います。
morph には専用のイベントがあります(第10章)。
document.addEventListener("turbo:before-morph-element", (event) => console.log("morph", event.target))
turbo:before-morph-element を観察すると、どの要素が morph の対象になっているかが分かります。data-turbo-permanent で保持しているはずの要素が morph されていないか、逆に保持したい要素が差し替わっていないか、を確かめられます。
29.6 フォーカス崩れ・読み上げ崩れを切り分ける
部分更新の後で、フォーカスが飛ぶ・読み上げが起きない、といった不具合もあります。アクセシビリティの方針は第7部で扱いました。この章では、その原因の切り分けに徹します。
- フォーカスが今どこにあるかは、Console で
document.activeElementを見れば分かります。差し替えの前後でフォーカスがどう動いたかを追えます。 - 読み上げ領域(
aria-live)が効いているかは、DevTools のアクセシビリティツリーで、その要素の role や live 設定を確認します。
「どこで差し替えが起きて、その結果フォーカスや読み上げがどうなったか」を、29.2 のイベントログと合わせて追うと、原因の場所が絞れます。
29.7 よくあるエラーの読み方
最後に、頻出のエラーです。多くは、ここまでの観察で原因にたどり着けます。
- 「Content missing」「frame に案内メッセージ」… Frame の
id不一致(29.4)。 - フォームを送ったのに何も起きない … 失敗を 200 で返している(29.1 でステータスを確認)。
- Stimulus が動かない … controller の接続ログが出ない(29.3)。
- 更新の命令が効かない … stream の
targetが存在しない(29.4)。
代表的なエラーと対処は、付録Eにまとめます。困ったら、まず Network、次に Turbo イベント、Stimulus、target、morph。この順で見れば、たいていの不具合は場所が分かります。
第29章では、不具合の場所を切り分ける観察の道具を整えました。次の第30章では、Hotwire の体感速度を損なう、Rails 側のパフォーマンス問題(とくに N+1)を扱います。
参考資料
- Turbo のイベントリファレンス: https://turbo.hotwired.dev/reference/events
- Stimulus リファレンス(Lifecycle Callbacks): https://stimulus.hotwired.dev/reference/lifecycle-callbacks
- Turbo Frames(Handbook): https://turbo.hotwired.dev/handbook/frames
第30章 パフォーマンスと N+1
この章のねらい
Hotwire は、画面を部分更新するので、体感が軽くなります。しかし、それは「サーバーが速い」という意味ではありません。サーバー側が遅ければ、部分更新であっても遅いままです。
この章では、Hotwire の体感速度を損なう Rails 側の問題を見つけて直します。とくに N+1 クエリは、Relay のような関連の多いアプリで起きやすい問題です。
30.1 Hotwire は遅い Rails を隠さない
第8部の軸を、ここで思い出します。「Hotwire は、遅い Rails も危ない Rails も隠してくれない」。
部分更新は、転送する HTML を小さくします。しかし、その HTML を作るのは Rails です。一覧を描くのに大量のクエリが走っていれば、frame でも stream でも、生成は遅いままです。Hotwire を入れたから速くなった、と安心せず、サーバー側の生成コストを測ります。
30.2 partial rendering のコスト
Relay の一覧は、_task partial をタスクの数だけ描きます。partial を 1 件ずつ描くのには、それなりのコストがあります。
Rails のコレクション描画(render @tasks)は、同じ partial を繰り返すときに最適化が効くので、1 件ずつ render を呼ぶより効率的です。一覧は、できるだけコレクション描画でまとめて描きます。それでも重いときは、30.5 のキャッシュを検討します。
30.3 N+1 と preload
Relay のタスクは、担当者(assignee)・タグ(tags)・コメント(comments)を持ちます。一覧で各タスクの担当者名やタグを表示すると、タスク 1 件ごとに関連を引くクエリが走ります。これが N+1 です。
たとえば、20 件のタスクを表示するのに、担当者を引くクエリが 20 回、タグを引くクエリが 20 回……と積み重なります。一覧の表示が、急に遅くなります。
直し方は、関連を先読み(preload)することです。includes を使います。
app/controllers/tasks_controller.rb(index)
@tasks = Task.includes(:assignee, :tags).order(:id)
includes(:assignee, :tags) を付けると、担当者とタグをまとめて読み込み、N+1 が解消します。一覧で使う関連を、includes で先読みします。
コメントは少し事情が違います。一覧に出したいのが「コメント件数」だけなら、includes(:comments) でコメント本体を全部読み込むのは無駄です。件数用途では、counter_cache(件数をカラムに持たせる仕組み)を使うのが効率的です。一覧で本文まで使うなら先読みする、件数だけなら counter_cache、と用途で使い分けます。
ここで大事なのは、第18章で触れた点です。broadcast の partial でも N+1 は起きます。配信のたびに関連を引けば、配信が遅くなります。配信に使う partial でも、必要な関連を先読みします。
30.4 broadcast の回数
リアルタイム更新(第18章)は、配信の回数にも気を配ります。
broadcasts_to は、レコードの保存ごとに配信します。たくさんのレコードを次々に保存する処理では、そのたびに配信が走り、サーバーにもクライアントにも負荷がかかります。
- 重い配信は、非同期版(
broadcast_*_later_to)でジョブに逃がします(第18章)。 - 大量の更新を一度にするなら、1 件ずつの配信ではなく、「最新に揃えて」と伝える broadcast refresh(第9章・第15章)を検討します。
30.5 キャッシュの使いどころ
描画そのものを減らすには、フラグメントキャッシュが効きます。partial の描画結果をキャッシュし、変わっていなければ作り直しません。
<% cache task do %>
<%= render task %>
<% end %>
cache task は、task のキャッシュキー(更新時刻を含む)でキャッシュします。タスクが変わればキーが変わり、自動でキャッシュが切り替わります。一覧と部分更新で同じ partial を共通化してあれば(第12章・第17章)、どちらの描画でも同じキャッシュが効きます。partial の共通化は、保守だけでなく、キャッシュの面でも利きます。
30.6 大きすぎる Turbo Streams
Turbo Streams は、1 レスポンスに複数の命令を入れられます(第15章・第17章)。便利ですが、入れすぎると重くなります。
たとえば、一度に何百行も append する命令を返すと、サーバーの描画も、ブラウザの適用も重くなります。大量のデータは、ページネーション(第24章)で分けて送ります。1 レスポンスの命令は、必要な分にとどめます。
30.7 測定してから直す
最後に、いちばん大事な原則です。推測で直さず、測ってから直します。
- サーバーログを見れば、1 リクエストで走ったクエリの数と時間が分かります。N+1 は、同じようなクエリが並ぶので、ログで見つけられます。
- N+1 を自動で検出する gem(Bullet など)を開発環境に入れると、見落としを減らせます。
- どこが遅いかは、プロファイラ(rack-mini-profiler など)で測ります。
「ここが遅いはず」という思い込みで includes を撒くと、かえって無駄な読み込みが増えることもあります。測って、遅い箇所を特定してから直す。これが順序です。
第30章では、Hotwire の体感を支える Rails 側の性能を扱いました。次の第31章では、部分更新や broadcast でも認可を崩さないための、認証・認可・セキュリティを学び、第8部を締めます。
参考資料
- Rails ガイド「Active Record クエリインターフェイス」: https://guides.rubyonrails.org/active_record_querying.html
- Rails ガイド「Rails のキャッシュ機構」: https://guides.rubyonrails.org/caching_with_rails.html
- Rails ガイド「Active Support の Instrumentation」: https://guides.rubyonrails.org/active_support_instrumentation.html
第31章 認証、認可、セキュリティ
この章のねらい
Hotwire は、画面の更新方法を変えますが、セキュリティの責任は変えません。部分更新でも broadcast でも、「誰が何をしてよいか」を守るのは、通常の Rails と同じです。
この章では、Hotwire を使うときに崩しやすい認可の落とし穴と、その守り方を学びます。第8部の軸「Hotwire は危ない Rails を隠さない」の総仕上げです。本書のサンプル Relay は単一チーム前提なので、ここは要点を押さえる範囲にとどめ、考え方を示します。
31.1 controller の認可を省略しない
Hotwire を使っても、リクエストは controller を通ります。frame の読み込みも、Turbo Streams を返す送信も、すべて通常の controller アクションです。
だから、認可は controller で行います。「ログインしているか」「その操作をしてよいユーザーか」を、これまでどおり controller で確認します。Hotwire だから特別なことをする、のではありません。むしろ、Hotwire だからといって認可を省略してよい場所は 1 つもありません。
31.2 Frame / Stream でも権限を確認する
つまずきやすいのは、「これは部分的な HTML だから」と気を抜くことです。
frame の src が指すアクションも、Turbo Streams を返すアクションも、独立したリクエストです。一覧ページで認可していても、frame が読み込むアクションを直接叩かれたら、そのアクションが無防備なら通ってしまいます。
部分を返すアクションにも、ページを返すアクションと同じ認可をかけます。「ページ全体は守ったから安心」ではなく、「部分を返す入口も同じく守る」と考えます。
31.3 broadcast の配信範囲
リアルタイム更新(第18章)では、配信範囲が認可と直結します。
broadcast は、購読している全員に届きます。配信先(streamable)を広く取りすぎると、見せてはいけない相手にまで更新が飛びます。たとえば、あるプロジェクトの更新を全ユーザー共通の配信先に流せば、無関係なユーザーにも届きます。
配信先は、「その更新を見てよい範囲」に合わせます。プロジェクト単位で見せるなら、プロジェクトを配信先にします。そして、配信する内容に、見せてはいけない情報を含めない。配信は受け取った全員の画面に出ることを、常に意識します。
31.4 署名付き stream 名への購読
ここで、誤解しやすい点をはっきりさせます。turbo_stream_from が作る購読名は、署名されています。これは購読名の改ざんを防ぐもので、第三者が購読名を推測・改変して別の配信を盗み聞きすることを、難しくします。
しかし、これは認可ではありません。署名は「購読名が正規のものか」を保証するだけで、「そのユーザーがそれを見てよいか」は判断しません。
アクセス制御は、別途行います。見てよいユーザーにだけ turbo_stream_from を描く(第27章で current_user がいるときだけ購読したのも、その一例)、配信内容に秘密情報を含めない、配信元の controller / model で権限を確認する。署名はその上での、改ざん防止の一枚です。
31.5 CSRF とフォーム
Rails は、フォーム送信に対する CSRF 保護を備えています。Hotwire でも、これは効いています。
form_with で作ったフォームには、CSRF トークンが埋め込まれます。Turbo は、フォーム送信のとき、このトークンを含めて送ります。だから、これまで作ってきたフォームは、特別なことをしなくても CSRF 保護の下にあります。
注意が要るのは、フォームを使わず、自分で fetch などを書いてサーバーを叩く場合です。そのときは、csrf-token の meta タグ(レイアウトの csrf_meta_tags が生成します)からトークンを読み、自分でリクエストに含める必要があります。フォームと Turbo に任せている限りは、保護は自動で効きます。
31.6 ユーザーごとの DOM id
第17章で、dom_id を使って id を付けました。dom_id(task) は "task_1" のような、推測しやすい id です。
ここに、油断の余地があります。id が推測できるからといって、認可をその曖昧さに頼ってはいけません。「id が分からないから安全」は、守りになりません。id を推測されても問題ないよう、controller で認可します。
ユーザーごとに分けたい配信では、推測しやすい共通の id ではなく、署名付きの stream 名(31.4)で購読を分けます。id の付け方そのものは推測されうる前提で、その上で認可と署名で守る、と考えます。
31.7 第18章との責務分担
第18章では、broadcast の「仕組み」を扱いました。turbo_stream_from で購読し、broadcasts_to や broadcast_*_to で配信する、という配信の仕方です。
この章は、その上の「責任」を扱いました。配信先を正しく絞る、内容に秘密を含めない、署名は認可ではないと理解する、controller で認可する。仕組みが分かったうえで、誰に何を届けてよいかを設計するのが、この章の役割です。
Relay は単一チーム前提なので、実装は最小限です。しかし、マルチテナントや権限の分かれるアプリでは、ここが設計の中心になります。Hotwire の部分更新・broadcast を足しても、認可の原則は変わらない。これが、第8部を通じての結論です。
第31章で、第8部を締めます。テスト・デバッグ・性能・セキュリティと、育てた Relay を保守する観点を見てきました。次の第9部では、この Relay を、書き直さずにモバイルへ広げる Hotwire Native を学びます。
参考資料
- Rails セキュリティガイド: https://guides.rubyonrails.org/security.html
- Rails ガイド「Action Cable の概要」: https://guides.rubyonrails.org/action_cable_overview.html
- turbo-rails(Streams / Broadcastable): https://github.com/hotwired/turbo-rails
ハンズオン(第8部): System Test で Hotwire UI を検証する
第7部までに作った Relay を、テスト・性能・セキュリティの面から固めます。
この部の到達状態
- 主要フローを覆う System Test 一式がある
assignee/tags/commentsの N+1 をincludesで解消してある- broadcast の配信先を controller / model 側で絞り、購読は署名付き stream 名で行っている(署名は購読名の改ざん防止であって認可ではない)
- 「Network → Turbo イベント → Stimulus → target id」の順で原因を切り分けられる
作る・変える
- テスト戦略に沿って層を配置し、フレークしない System Test を書く
- N+1 をログで計測してから解消する
- broadcast の配信先を controller / model 側で絞り、購読に署名付き stream 名を使う
完成条件
- テストスイートがフレークなしで緑になる
- N+1 がログから消える
- 他ユーザーに見せてはいけない更新が broadcast されない
Relay の現在地
Relay が壊れにくく・速く・安全になった状態。 次の第9部で、同じ Relay をモバイルへ広げます。
第9部 Hotwire Native
第1〜8部で作った Relay を、書き直さずにモバイルへ広げます。Hotwire Native は、既存の Web 画面をそのまま WebView で表示し、必要な部分だけネイティブに置き換える Web-first な構成です。
本編(第32〜35章)は考え方と設計に絞ります。Xcode / Android Studio を要する実機ビルドの手順は付録Hに分離してあるので、ネイティブ開発環境がない読者も本編を読み進められます。
第32章 Hotwire Native の考え方
この章のねらい
ここまで育てた Relay は、Web アプリです。Hotwire Native を使うと、この Web アプリを書き直さずに、iOS / Android のモバイルアプリへ広げられます。
第9部では、その考え方を学びます。実機でビルドする手順(Xcode / Android Studio が必要です)は付録Hに分けてあるので、ネイティブ開発環境がなくても、この部は読み進められます。
この部を貫く軸は「同じ Relay を、ネイティブの殻で包む」です。Hotwire Native は、既存の Web 画面をそのまま表示し、必要な部分だけネイティブに置き換える、Web-first な構成です。
32.1 Hotwire Native とは
Hotwire Native は、モバイルアプリの作り方の一つです。アプリの画面の多くを、サーバーが返す Web 画面(HTML)で構成し、それをネイティブアプリの中で表示します。
ふつう「ネイティブアプリ」というと、画面を一つひとつネイティブのコードで作ります。Hotwire Native は、その逆の発想です。すでにある Web 画面を活かし、ネイティブはそれを包む殻として使う。だから、Relay のように Web で作り込んだアプリを、最小限の追加でモバイルに持っていけます。
Web を主役にするので、機能の追加・修正は、これまでどおりサーバー側で行えます。Web 画面については、アプリをストアに出し直さなくても、Web を更新すれば、モバイルの表示も変わります。これが Web-first の利点です。
ただし、これは Web 画面に限った話です。native shell そのものや、後の章で扱うネイティブ画面・Bridge Components を変えるときは、アプリをビルドし直し、ストアで配信(再申請)する必要があります。「Web で済む部分はストアを介さず更新でき、ネイティブの部分はストア配信が要る」と区別して覚えてください。
32.2 WebView と native shell
Hotwire Native のアプリは、大きく 2 つの部分でできています。
- WebView … Web 画面(Relay の HTML)を表示する部分。中身は、これまで作ってきた Web そのものです。
- native shell(ネイティブの殻) … WebView を包む、ネイティブの枠組み。画面遷移(ナビゲーションバーや戻る操作)、タブ、画面の出し方(プッシュ遷移かモーダルか)などを、ネイティブとして提供します。
ユーザーから見ると、ナビゲーションや遷移はネイティブの操作感で、中身の画面は Web、という形になります。Turbo が Web で実現していた高速な画面遷移が、ネイティブのナビゲーションと組み合わさります。
32.3 すべてをネイティブ化しない判断
Hotwire Native の肝は、「どこまで Web のままにし、どこをネイティブにするか」の判断です。
すべてをネイティブのコードで作り直すと、Web-first の利点(更新の速さ、コードの共有)が消えます。一方で、Web だけでは難しい部分もあります。カメラ、決済、プッシュ通知、複雑なジェスチャーなどです。
考え方は、こうです。原則は Web のまま。ネイティブでしか作れない、あるいはネイティブの方が明らかに良い部分だけを、ネイティブにする。多くの画面は Web のままで十分です。Relay のタスク一覧や詳細、フォームは、Web のまま動きます。
32.4 Web 側に求められる設計
Web を主役にする以上、Web 側の品質が、そのままモバイルの品質になります。Hotwire Native のために、Web 側に求められることがあります。
- レスポンシブ。モバイルの画面幅で、きちんと見える・操作できること。
- 素直なナビゲーション。リンクと遷移が、ネイティブの画面スタックに自然に乗ること。URL と画面状態が対応していること(第14章)。
- 認証の共有。WebView は Web のセッションを使います。Web 側のログイン(第5章)が、そのままモバイルでも効きます。
ここで効いてくるのが、第7部までの作り込みです。フォームの UX、モーダル、通知を Web できちんと作ってあれば、その多くはモバイルでもそのまま活きます。Web の HTML が整っているほど、ネイティブ化の追加コストは小さくなります。
32.5 iOS / Android の大まかな違い
Hotwire Native は、iOS と Android の両方に対応します。
- iOS … Swift で書きます。
- Android … Kotlin で書きます。
native shell のコードは、それぞれのネイティブ言語で書くので、言語と細部は異なります。しかし、考え方(WebView + native shell、Path Configuration、Bridge Components)は共通です。本書では、両方に共通する考え方を中心に扱います。具体的なセットアップとコードは、付録Hで扱います。
第32章では、Hotwire Native の全体像を、Web-first という考え方で押さえました。次の第33章では、URL ごとに画面の出し方を決める Path Configuration を学びます。
参考資料
- Hotwire Native: https://native.hotwired.dev/
- Hotwire: https://hotwired.dev/
第33章 Path Configuration
この章のねらい
第32章で、Hotwire Native は Web 画面を native shell で包む、と学びました。では、「この URL はモーダルで開く」「この URL はプッシュ遷移する」といった、ネイティブの見せ方は、どこで決めるのでしょうか。
それを担うのが Path Configuration です。この章では、URL ごとに画面の出し方を決める仕組みと、その考え方を学びます。
33.1 Path Configuration の役割
Path Configuration は、URL のパターンごとに、ネイティブでの見せ方を対応づける設定です。JSON で書きます。
native shell は、WebView が URL を開こうとするたびに、この設定を見ます。そして、「この URL はモーダルで」「この URL は通常のプッシュ遷移で」と、設定に従って画面を出します。Web 側のコードを変えずに、ネイティブの振る舞いを URL 単位で制御できる、というのが役割です。
33.2 URL pattern
設定は、ルール(rules)の集まりです。各ルールは、URL のパターンと、その振る舞い(プロパティ)を持ちます。パターンは、パスに対する正規表現で書きます。
{
"settings": {},
"rules": [
{
"patterns": ["/tasks/new", "/tasks/\\d+/edit"],
"properties": { "context": "modal" }
}
]
}
この例では、/tasks/new と /tasks/123/edit のようなパスに、あるプロパティを当てています。パターンに当てはまらない URL は、既定の振る舞い(通常のプッシュ遷移)になります。なお、properties の中身(context などのキー名や値)は、ここでは構造を示すための例です。実際に使えるキーと値は 33.3 と公式ドキュメント(付録H)で確認してください。
33.3 presentation
ルールのプロパティで、画面の出し方を指定します。代表的なのが、モーダルで出すか、プッシュで出すか、です。
上の例の "context": "modal" は、その URL をモーダルとして出す指定です。タスクの新規作成や編集を、モーダルで重ねて表示できます。指定がなければ、画面はナビゲーションに積まれる通常のプッシュ遷移になります。
プロパティの正確なキー名や指定できる値は、Hotwire Native のバージョンによって異なります。本書では考え方を示すにとどめ、実際のキー名と値は公式ドキュメントと付録Hで確認します。大切なのは、「URL に対して見せ方を割り当てる」という構造です。
33.4 rules の管理
Path Configuration は、アプリに同梱することも、サーバーから配信することもできます。
サーバーから配信すると、アプリをストアに出し直さずに、ルールを更新できます。「この画面はモーダルに変えたい」と思ったとき、サーバー側の設定を変えるだけで、配布済みのアプリの振る舞いを変えられます。第32章で見た「Web で済む部分はストアを介さず更新できる」という利点が、ここにも及びます。
ただし、これはあくまでルール(見せ方の対応づけ)の更新です。native shell の機能そのものを変えるなら、アプリの再配信が要ります(第32章)。
33.5 Web 側ルーティングとの関係
Path Configuration は、URL のパターンで振る舞いを決めます。つまり、Web 側の URL 設計が、そのままネイティブの制御の土台になります。
URL が素直に設計されていれば、パターンも素直に書けます。逆に、URL と画面の状態がずれていると(第14章)、パターンでの制御が難しくなります。たとえば、モーダルで開きたい画面が /tasks/new のような明確な URL を持っていれば、そこにモーダルの指定を当てるだけです。
ここで、第7部の第26章とつながります。モーダルを「URL を持つ画面」として設計しておくか(ディープリンク)、URL を変えずに開くか、という判断が、ネイティブでの制御のしやすさにも効きます。Web の URL 設計は、ネイティブのためにも効いてくるのです。
第33章では、URL ごとに見せ方を決める Path Configuration を学びました。次の第34章では、Web とネイティブの境界をつなぐ Bridge Components を学びます。
参考資料
- Hotwire Native: https://native.hotwired.dev/
- Hotwire Native(Path Configuration): https://native.hotwired.dev/
第34章 Bridge Components
この章のねらい
Path Configuration(第33章)は、URL ごとの見せ方を決めるものでした。しかし、画面の中の一部分を、ネイティブの UI で表示したいこともあります。たとえば、ナビゲーションバーの右上に、ネイティブのボタンを置きたい、といった場合です。
それを実現するのが Bridge Components です。Web とネイティブの境界をつなぎ、Web の要素をきっかけにネイティブの UI を出します。この章では、その考え方を学びます。
34.1 Bridge Components とは
Bridge Components は、Web 側の宣言をきっかけに、ネイティブの UI 部品を動かす仕組みです。
たとえば、Web のフォームに「このフォームには送信ボタンが要る」と印を付けておくと、ネイティブ側がそれを受け取り、ナビゲーションバーにネイティブの送信ボタンを表示します。ボタンが押されると、ネイティブから Web へ「押された」と伝わり、Web がフォームを送信します。
Web とネイティブが、メッセージをやり取りして協調します。Web は「何が要るか」を宣言し、ネイティブは「それをネイティブの見た目で実現する」役割です。
34.2 Web 側のマークアップ
ここで効いてくるのが、第6部の Stimulus です。Bridge Components の Web 側は、Stimulus の controller の上に乗ります。
Web 側では、data-controller でブリッジ用の controller を要素に結びつけます。
<form data-controller="submit-button">
...
</form>
この submit-button という controller が、ネイティブ側へ「送信ボタンを出して」と伝えるブリッジになります。ブリッジ用の controller は、Stimulus の controller を、ネイティブと通信できるように拡張したものです(基底クラスや拡張の仕方は Hotwire Native のバージョンによります。詳細は付録Hと公式ドキュメントで確認します)。
大切なのは、Web 側はあくまで Stimulus の延長で書ける、という点です。第6部で学んだ controller の知識が、そのまま土台になります。
34.3 ネイティブ側の component
Web 側の宣言を受け取って、実際にネイティブの UI を出すのが、ネイティブ側の component です。
iOS なら Swift、Android なら Kotlin で書きます(第32章)。Web 側の submit-button controller に対応するネイティブの component を用意し、「submit-button が現れたら、ナビゲーションバーに送信ボタンを出す」といった処理を書きます。
Web 側の controller 名と、ネイティブ側の component が、名前で対応づきます。Web の宣言と、ネイティブの実装が、ペアになっている、と考えてください。
34.4 メッセージの送受信
Web とネイティブは、メッセージをやり取りします。
- Web → ネイティブ。「送信ボタンを、このラベルで出して」といった指示を送ります。ボタンのラベルや状態など、必要な情報を渡します。
- ネイティブ → Web。「ボタンが押された」といった出来事を伝えます。Web 側はそれを受け取り、フォームを送信するなどの処理をします。
この往復で、Web の状態(フォームの内容など)と、ネイティブの操作(ボタンのタップ)が、つながります。Web 側の受け取りは、ブリッジ用 controller のメソッドとして書きます。ここも Stimulus の延長です。
34.5 使いすぎを避ける判断
Bridge Components は強力ですが、使いすぎは禁物です。
何でもネイティブの部品に置き換えると、Web 側とネイティブ側の両方に実装が要り、二重管理になります。第32章の「すべてをネイティブ化しない」が、ここでも効きます。
判断は、こうです。Web の見た目で十分なら、ブリッジは架けない。ネイティブの部品でなければ実現できない、あるいはネイティブの方が明らかに体験が良い箇所だけ、ブリッジを使います。Relay の多くの画面は、ブリッジなしの Web のままで動きます。ナビゲーションバーのボタンのように、ネイティブの定位置に置きたいものだけを、ブリッジで橋渡しします。
もう 1 つ、忘れてはいけない前提があります。同じ Web ページは、ネイティブアプリの中だけでなく、通常のブラウザでも開かれます(Web-first)。ブラウザには対応するネイティブ component がないので、ブリッジは何も起こしません。だから、Bridge Components はプログレッシブ・エンハンスメントとして設計します。つまり、ネイティブがあれば良くなる、なくても Web だけで成立する、という形にします。先の例なら、Web 側に通常の送信ボタンを残しておき、ネイティブではそれをナビゲーションバーのボタンで置き換える、と考えます。こうすれば、ブラウザでもアプリでも壊れません。
第34章では、Web とネイティブをつなぐ Bridge Components を、Stimulus の延長として学びました。次の第35章では、Web ではなくネイティブ画面そのものが必要になる場面と、その責務分担を扱い、第9部を締めます。
参考資料
- Hotwire Native: https://native.hotwired.dev/
- Stimulus Handbook: https://stimulus.hotwired.dev/handbook/introduction
第35章 Native Screens
この章のねらい
第33章・第34章では、Web 画面を土台に、見せ方(Path Configuration)と部品(Bridge Components)をネイティブで補いました。しかし、ときには画面そのものを、丸ごとネイティブで作る方がよい場面もあります。
それが Native Screens です。この章では、ネイティブ画面が必要になる場面と、Web 画面との責務分担を学び、第9部を締めます。
35.1 Native Screens とは
Native Screens は、WebView を使わず、完全にネイティブのコードで作る画面です。iOS なら Swift、Android なら Kotlin で、画面を一から組みます。
これは、Hotwire Native の Web-first の例外です。原則は Web 画面ですが、Web では難しい・ネイティブの方が明らかに良い画面だけ、ネイティブで作ります。Web 画面とネイティブ画面が、同じアプリのナビゲーションの中に混在する形になります。
35.2 ログイン、決済、カメラなどの候補
ネイティブ画面が向くのは、次のような画面です。
- カメラ・センサー。カメラ撮影や位置情報など、デバイスの機能を深く使う画面。
- 決済。Apple Pay や Google Pay、ストアの課金など、ネイティブの仕組みが要る決済。
- ログイン。生体認証(指紋・顔)や、ネイティブのパスワード管理と連携したいログイン。
逆に言えば、それ以外の多くの画面は Web のままで十分です。Relay のタスク管理の中心は、Web 画面で動きます。ネイティブ画面は、「Web では届かない」ところに絞ります。
35.3 WebView へ戻る導線
ネイティブ画面と Web 画面が混在するので、両者を行き来する導線が要ります。
たとえば、ネイティブのログイン画面でログインしたあと、Web のタスク一覧へ進む。あるいは、Web の画面からネイティブのカメラ画面を開き、撮影が終わったら Web へ戻る。こうした遷移を、native shell のナビゲーションの中で設計します。
ユーザーから見ると、ネイティブ画面も Web 画面も、同じアプリの中の画面です。境目を感じさせない遷移にするのが理想です。
35.4 状態同期
ネイティブ画面と Web 画面が混在するとき、いちばん注意が要るのが状態の同期、とくに認証です。
たとえば、ネイティブのログイン画面で認証したとします。その結果(ログイン済みであること)を、Web 側の WebView にも引き継がないと、Web 画面では「ログインしていない」ことになってしまいます。
WebView は、Web のセッション(cookie)を使います(第32章)。だから、ネイティブで認証した結果を、WebView のセッションに反映する受け渡しが要ります。ここは、第31章で見た認証・セキュリティの考え方が、ネイティブとの境界でも問われる場面です。トークンやセッションの受け渡しを、安全に設計します。具体的な受け渡しの仕組み(cookie やトークンを WebView にどう渡すか)は実装寄りの話なので、付録Hで扱います。
35.5 テストと配布の注意点
ネイティブ画面は、アプリのバイナリの一部です。ここから、Web 画面とは違う制約が生まれます。
- 配布。ネイティブ画面を追加・修正したら、アプリをビルドし直し、ストアで配信(審査)します。Web 画面のように、サーバーを更新して即反映、とはいきません(第32章)。更新サイクルが、Web より遅く・重くなります。
- テスト。ネイティブ画面のテストは、ネイティブのテスト(iOS / Android のテスト)になります。Web 側の System Test(第28章)とは別の仕組みです。
だからこそ、ネイティブ画面は最小限にします。ネイティブ画面が増えるほど、Web-first の利点(速い更新サイクル、Web に寄せたテスト)が薄れ、ふつうのネイティブアプリ開発に近づいていきます。「本当にネイティブでなければならないか」を、配布とテストのコストまで含めて判断します。
第35章で、第9部を締めます。Hotwire Native を、Web-first という一貫した考え方で見てきました。Path Configuration で見せ方を決め、Bridge Components で部品を補い、どうしても必要なところだけ Native Screens にする。Web の Relay を土台に、モバイルへ無理なく広げられます。実機でのビルドは、付録Hで扱います。次の第10部では、ここまでの全体を振り返り、「Hotwire を選ぶべきか」を考えます。
参考資料
- Hotwire Native: https://native.hotwired.dev/
- Rails セキュリティガイド(認証): https://guides.rubyonrails.org/security.html
演習(第9部): ネイティブ化の構成を設計する
実機ビルドの手順は付録Hで扱います。この演習は、ネイティブ開発環境がなくても取り組める「設計」に絞ります。
この部の到達状態
- Relay のどの URL をモーダル / プッシュで見せるかを Path Configuration として設計できる
- どの画面を Web のまま残し、どこをネイティブ画面にするかを判断できる
- Bridge Component が必要になる箇所を 1 つ挙げて理由を説明できる
演習
- Relay の主要 URL に presentation(modal / push)を割り当てる
- ネイティブ化の候補(ログインなど)と、Web のまま残す画面を仕分ける
- 状態同期(認証・セッション)で注意する点を、第31章と結びつけて挙げる
Relay の現在地
同じ Relay を Web とモバイルで動かす構成を、設計レベルで描けた状態。 実機ビルドへ進む人は付録Hへ向かいます。
第10部 Hotwire を選ぶべきか
Relay を題材に、Hotwire のアンチパターン、React / Vue / SPA との使い分け、そして Hotwire の今後を振り返ります。「使える」ではなく「使い続けて保守できる」という視点で、採用判断の軸を持つことがこの部のゴールです。
第36章 Hotwire のアンチパターン
この章のねらい
第10部では、ここまで作ってきた Relay を振り返り、「Hotwire を選ぶべきか」を考えます。最初は、Hotwire を使うほど苦しくなる、典型的なアンチパターンです。
これらは、本書のあちこちで「使いすぎに注意」と触れてきたものの総まとめです。共通するのは、「できる」と「読みやすく保てる」を取り違えると、複雑さを抱え込む、ということです。
なお、この章が集めるのは構造・設計レベルのアンチパターンです。実装レベルの具体的な落とし穴(フォームの失敗を 422 ではなく 200 で返す=第8章・第25章、N+1=第30章、署名付き stream 名を認可と取り違える=第31章など)は、それぞれの章のアンチパターン節にまとめてあります。本章だけで落とし穴が尽きるわけではない、と捉えてください。
この部を貫く視点は「使える」ではなく「使い続けて保守できる」です。
36.1 すべてを Frame に入れる
Turbo Frames は便利なので、何でも frame で囲みたくなります。しかし、frame を増やすほど、入れ子が深くなり、「どのリンクでどこが差し替わるか」が追えなくなります(第14章)。
frame は、「ページの一部を、周りと独立して差し替えたい」ときの道具です。ページ全体が変わる遷移にまで frame を使うと、URL のズレや入れ子の複雑さだけを抱えます。1 か所の独立した更新に絞って使います。
36.2 controller が分岐だらけになる
「frame からのリクエストか」「Turbo Streams を返すか」で、controller が if 分岐だらけになることがあります(第14章)。
多くの場合、同じビューに frame を置いておけば、Turbo が必要な部分を取り出してくれます。まずは、リクエストの種類で分岐を増やさずに済まないかを考えます。分岐が増えてきたら、それは設計を見直す合図です。
36.3 Stimulus に状態管理を押し込む
Stimulus に、アプリの状態をため込みたくなることがあります。controller のインスタンス変数に、あれこれ持たせる形です。
しかし、Stimulus の状態は、Turbo がページや frame を差し替えると失われます(第21章)。状態は HTML 側(data 属性やクラス)に置き、サーバーが持つべき状態はサーバーに任せます。Stimulus は「振る舞いを足す小さな JavaScript」であって、状態管理の置き場所ではありません。複雑な状態管理が要るなら、それは Hotwire が向かない兆候かもしれません(第37章)。
36.4 broadcast が広すぎる
リアルタイム更新は強力ですが、配信先を広く取りすぎると、無関係なユーザーにまで更新が飛びます(第18章・第31章)。無駄な通信が増え、見せてはいけない情報が漏れる危険もあります。
配信先は、「その更新を見てよい範囲」に正確に合わせます。また、保存のたびに無条件で broadcast すると、回数が膨らみます(第30章)。「本当にリアルタイムで全員へ届ける必要があるか」を、その都度問います。
36.5 URL と画面状態が一致しない
frame や Stimulus で画面を作り込むほど、いま見えている状態が URL から復元できなくなりがちです(第14章)。リロードで状態が消え、共有しても同じ画面が出ず、戻る操作が期待とずれます。
主要な画面の状態は、URL に残します(data-turbo-action="advance" など)。URL は、Web の基本的な「場所」です。Hotwire を使っても、ここを壊さないようにします。
36.6 a11y を後回しにする
部分更新は、目で見ているユーザーには自然でも、スクリーンリーダーやキーボードのユーザーには伝わりにくいことがあります(第7部)。フォーカスが飛ぶ、更新が読み上げられない、といった問題です。
a11y を「後で」にすると、部分更新が積み重なった後では直しにくくなります。フォーカスの移動、aria-live での読み上げ、キーボード操作を、作るときから織り込みます。第7部の a11y チェックリストを、習慣にします。
36.7 通常の Rails に戻す判断
最後に、いちばん大事なアンチパターンへの対処です。Hotwire で無理をしていると感じたら、通常の Rails に戻す。
frame の入れ子と格闘している、controller が分岐だらけ、Stimulus に状態を詰め込んでいる。そんなときは、その画面が本当に Hotwire 向きだったかを問い直します。ふつうのページ遷移(Turbo Drive のまま)で十分なら、そこに戻すのが正解です。
判断の基準は、繰り返しになりますが、「できるか」ではなく「読みやすく保てるか」です。Hotwire は道具であって、目的ではありません。
第36章では、Hotwire のアンチパターンを総まとめしました。次の第37章では、視野を広げて、React / Vue などの SPA と Hotwire の使い分けを考えます。
参考資料
- Turbo Handbook(Building Your Turbo Application): https://turbo.hotwired.dev/handbook/building
- Hotwire: https://hotwired.dev/
第37章 React / Vue / SPA との使い分け
この章のねらい
Hotwire を学ぶと、「では React や Vue のような SPA は要らないのか」と考えたくなります。しかし、これは「どちらが優れているか」の問題ではありません。向き不向きがあり、選択肢の問題です。
この章では、Hotwire と SPA を対立ではなく選択肢として比較し、どんなときにどちらが向くかを整理します。
37.1 比較する軸
両者を分ける主な軸は、次のとおりです。
- HTML を誰が組み立てるか。Hotwire はサーバー。SPA はクライアント(JavaScript)。
- 状態をどこに持つか。Hotwire は基本サーバー(と HTML)。SPA はクライアントに大きな状態を持つ。
- 通信に何を流すか。Hotwire は HTML。SPA は主に JSON。
- JavaScript の量。Hotwire は少なめ。SPA は多め。
この軸で見ると、両者は「描画と状態を、サーバー寄りに置くか、クライアント寄りに置くか」で分かれていると分かります。
ただし、これは高レベルの単純化です。近年は SPA 側にも、サーバーで描画するメタフレームワーク(Next.js など)があり、「サーバー描画かクライアント描画か」の線は実際にはぼやけています。ここでは大づかみの違いとして捉え、細部はそれぞれの技術で確認してください。
37.2 Hotwire が強い場面
Hotwire が向くのは、次のような場面です。
- サーバー中心の CRUD アプリ。Relay のような、データの一覧・作成・編集が中心のアプリ。
- コンテンツ中心のアプリ。記事や管理画面など、サーバーが持つデータを見せるのが主な役割のもの。
- Rails に強いチーム。サーバー側の知識を活かし、JavaScript の量を抑えたい場合。
- 速く作り、速く直したい。サーバー側で機能を足せるので、反復が速い。
多くの業務アプリは、この範囲に収まります。Relay も、Hotwire で無理なく作れました。
37.3 SPA が強い場面
一方、SPA が向くのは、次のような場面です。
- クライアント側の状態が大きい。描画ツール、表計算、複雑なフィルタを多段で持つダッシュボードなど、クライアントで重い状態を扱う UI。
- オフラインや高い即応性。ネットワークが切れても動く、ネイティブアプリ並みの操作感が要る場合。
- すでに API が中心。複数のクライアント(Web・モバイル・外部連携)が同じ API を使う設計。
「サーバー往復を挟まずに、クライアントだけで完結させたい操作」が多いほど、SPA が向きます。
37.4 混在させる場合
「全部 Hotwire か、全部 SPA か」だけが選択肢ではありません。混在もできます。
たとえば、アプリの大部分は Hotwire で作り、特に複雑な 1 つのウィジェット(高機能なエディタなど)だけ、React のコンポーネントを島のように埋め込む、という形です。Stimulus から、その島を初期化・破棄します(第22章の外部ライブラリ連携と同じ考え方です)。
「複雑なところだけクライアントに寄せ、それ以外はサーバー中心のまま」という混在は、現実的な選択肢です。最初から全部を SPA にしなくても、必要な箇所だけ強い道具を使えます。
37.5 API 設計が必要になる場面
判断の分かれ目の一つが、API です。
Hotwire は HTML を返すので、Web のためだけなら、JSON API は要りません。しかし、モバイルアプリ(ネイティブで作り込む場合)や、外部サービス連携で、どうせ JSON API が要るなら、話が変わります。
ただし、Hotwire Native(第9部)を使えば、モバイルのためだけに JSON API を用意しなくても済むことがあります。Web の HTML をそのまま使うからです。「本当に JSON API が必要か、それとも HTML で足りるか」を見極めると、不要な API 設計を避けられます。
37.6 チームのスキルと保守コスト
技術選定は、チームの事情も含めて決めます。
Rails に強く、JavaScript の専任が少ないチームなら、Hotwire は学習・保守のコストを抑えられます。サーバー側の知識がそのまま活きるからです。逆に、フロントエンドに強いチームで、すでに SPA の資産があるなら、SPA を選ぶ合理性もあります。
「技術的に何ができるか」だけでなく、「このチームが、これを何年も保守できるか」まで含めて判断します。これが、第10部の視点「使い続けて保守できるか」です。
37.7 採用判断チェックリスト
最後に、判断の目安をまとめます。次に多く当てはまるほど、Hotwire が向きます。
- データの一覧・作成・編集が中心である
- クライアント側に持つ状態は、それほど大きくない
- オフライン対応は要らない
- チームは Rails に強く、JavaScript は少なくしたい
- Web のためだけなら、JSON API は必須ではない
逆に、クライアント状態が大きい・オフラインが要る・すでに API 中心、に多く当てはまるなら、SPA や混在を検討します。Hotwire は万能ではありませんが、多くの業務アプリにとって、十分で、保守しやすい選択肢です。
第37章では、Hotwire と SPA を選択肢として比較しました。次の第38章では、Hotwire の現在地と今後を見て、本書を締めます。
参考資料
- Hotwire: https://hotwired.dev/
- Turbo Handbook(Introduction): https://turbo.hotwired.dev/handbook/introduction
第38章 Hotwire の未来
この章のねらい
本書の最後に、Hotwire の現在地と、これからの方向を見ます。技術は動き続けるので、ここでは確実なことを中心に、本書で学んだことがどこへつながるかを整理します。
なお、この章には将来の見通しが含まれます。本書の方針(確認日とバージョンを明記する)にならえば、これは 2026 年 6 月時点の見立てです。確実な事実と、筆者の予測は書き分けますが、最新の状況は一次情報で確かめてください。
38.1 Turbo 8 と morphing の意味
Turbo 8 で入った morphing(第9章)は、Hotwire の表現力を一段広げました。
それまで、部分更新は「差し替え」が基本でした。morphing は、新旧の DOM を比べて、変わった部分だけを当てます。これにより、入力中のフォーカスやスクロール位置を保ったまま、画面を最新にできます。「ページ全体を最新に揃えたいが、ユーザーの操作は邪魔したくない」という要求に、素直に応えられるようになりました。
morphing は、後述の broadcast refresh と組み合わさることで、リアルタイム更新の作り方を、より簡単な方向へ動かしました。
38.2 refresh broadcast の可能性
第15章・第18章で見た broadcast refresh は、リアルタイム更新の考え方を変えます。
これまでは、変化のたびに「この要素をこう変えろ」という細かい命令を、一つひとつ配信していました。broadcast refresh では、「ページを最新に更新して」と伝えるだけで済みます。受け取った各クライアントが、morphing で自分の画面を最新に揃えます。
細かい stream を設計する代わりに、「最新に揃える」という大きな単位で考えられます。これは、リアルタイム機能の実装を、シンプルにする方向です。今後、この作り方が標準的になっていくと考えられます。
38.3 Hotwire Native の成熟
第9部で見た Hotwire Native は、Turbo Native から名前と中身を整え、iOS と Android を一貫した考え方で扱えるようになりました。
Web の資産をそのままモバイルへ広げる、という発想は、Web-first のチームにとって現実的な選択肢です。Path Configuration や Bridge Components の整備が進むほど、「Web で作り、必要なところだけネイティブ」という形が、取りやすくなっていきます。
38.4 Rails 標準としての Hotwire
Hotwire は、Rails の既定です(第6章)。rails new すれば、最初から入っています。
これは、ただの「同梱」以上の意味を持ちます。Rails が「サーバーで HTML を返す」という基本に立ち、その上での画面更新の標準を Hotwire に置いた、ということです。本書で見てきた「HTML over the wire」は、Rails の思想と地続きです。Rails を使う限り、Hotwire は最初の選択肢であり続けます。
38.5 SPA との境界はどう変わるか
第37章で見たとおり、Hotwire と SPA の境界は、もともとはっきり分かれていませんでした。そして、その境界は今も動いています。
morphing のような技術で、Hotwire はサーバー中心のまま、より滑らかな更新を実現できるようになりました。一方、SPA 側もサーバー描画を取り込んでいます。両者は、互いの良いところを取り入れながら近づいています。
大切なのは、どちらが勝つかではなく、「自分のアプリに、どちらの考え方が合うか」を選べることです。本書は、その選択肢の一つとして、Hotwire を深く理解することを目指しました。
38.6 本書の後に学ぶこと
本書を終えたら、次の方向へ進めます。
- 公式ドキュメントを読む。本書の各章で挙げた一次情報(Turbo / Stimulus / Hotwire Native の公式)を、改めて通して読むと、理解が深まります。歩き方は付録Aにまとめます。
- 自分のアプリで使う。Relay で学んだパターンを、実際のアプリに当てはめます。うまくいかないときは、第8部のデバッグと、第10部の判断軸(戻す勇気)を思い出してください。
- 変化を追う。Hotwire は動き続けます。確認日とバージョンを意識し(本書の方針でもあります)、新しい機能は一次情報で確かめます。
本書で繰り返してきたのは、「HTML over the wire」という一つの考え方でした。サーバーが HTML を返し、ブラウザがそれを賢く反映する。Turbo Drive・Frames・Streams・Stimulus・Native は、すべてこの考え方の現れです。この軸さえ持っていれば、Hotwire のこれからの変化も、迷わず読み解けるはずです。
これで本編は終わりです。Relay という一つのアプリを育てながら、Hotwire を一通り学びました。付録では、公式ドキュメントの歩き方、各種リファレンス、よくあるエラー、そして Hotwire Native の実機ビルド手順を扱います。
参考資料
- Hotwire: https://hotwired.dev/
- Turbo(Page Refreshes / morphing): https://turbo.hotwired.dev/handbook/page_refreshes
- Hotwire Native: https://native.hotwired.dev/
演習(第10部): 採用判断を言葉にする
本書のゴールは、Hotwire を「選ぶ/選ばない」を自分の言葉で判断できるようになることです。
この部の到達状態
- Relay の中で「Hotwire で複雑になった箇所」を 1 つ挙げ、通常遷移や別設計への戻し方を説明できる
- SPA との比較軸で Relay を評価できる
演習
- Relay の一部を取り上げ、Turbo Frames / Turbo Streams のやりすぎを指摘して、簡素化案を書く
- 同じ機能を SPA で作る場合との違いを、開発コストと保守コストの観点で比較する
Relay の現在地
Hotwire の採用可否を、根拠をもって判断できる状態。本書のゴール。
付録
本編を補う参照資料とハンズオンを置きます。
- 付録A 公式ドキュメントの歩き方
- 付録B Turbo 属性・イベント一覧
- 付録C Turbo Streams アクション一覧
- 付録D Stimulus リファレンス
- 付録E よくあるエラーと対処
- 付録F AI に Hotwire コードを依頼するときのプロンプト集
- 付録G 完成版サンプルアプリのコード解説
- 付録H Hotwire Native ハンズオン(実機ビルド手順 / Xcode・Android Studio 前提)
付録A 公式ドキュメントの歩き方
本書は、Hotwire の考え方と実践を一通り扱いました。しかし、Hotwire は動き続けます。新しい機能や、細かな仕様は、一次情報(公式ドキュメント)で確かめるのが確実です。この付録では、公式ドキュメントの歩き方を案内します。
一次情報の入口
Hotwire の公式情報は、次の場所にあります。
- Hotwire(全体): https://hotwired.dev/
- Turbo: https://turbo.hotwired.dev/
- Stimulus: https://stimulus.hotwired.dev/
- Hotwire Native: https://native.hotwired.dev/
- Rails ガイド: https://guides.rubyonrails.org/
迷ったら、まずこの 5 つに当たります。第三者の記事は、入口としては便利ですが、情報が古いことがあります。最終的な確認は、必ず一次情報で行います。
Handbook と Reference の違い
Turbo と Stimulus の公式ドキュメントは、大きく 2 つに分かれています。
- Handbook … 考え方と使い方を、順を追って説明します。「どう使うか」を学ぶときに読みます。本書の各章も、Handbook に対応する内容を多く含みます。
- Reference … 属性・イベント・メソッドなどを、網羅的に並べます。「正確な名前や値」を確かめるときに引きます。本書の付録B〜Dも、この Reference に対応します。
学ぶときは Handbook、確かめるときは Reference、と使い分けます。
バージョンと変更を追う
Hotwire は更新されます。新機能(たとえば Turbo 8 の morphing)や、仕様変更を追うには、次を見ます。
- GitHub のリポジトリ(
hotwired/turbo、hotwired/turbo-rails、hotwired/stimulus、hotwired/hotwire-native-ios、hotwired/hotwire-native-android)。リリースノートと変更履歴(CHANGELOG)で、何が変わったかを確認できます。 - 本書の方針でもあるとおり、バージョンに依存する記述を読むときは、自分が使っているバージョンを意識します。手元のバージョンは、
Gemfile.lockやbin/importmap jsonで確認できます。
困ったときの調べ方
詰まったときは、次の順で当たると効率的です。
- エラーメッセージで、Reference や付録Eを引く。
- Handbook の該当章を読み直す。
- GitHub の Issues / Discussions で、同じ症状を探す。
- それでも分からなければ、最小の再現コードを作る。再現を作る過程で、原因に気づくこともよくあります。
一次情報を最優先に、確認日とバージョンを意識して調べる。これが、変化し続ける Hotwire と付き合うコツです。
付録B Turbo 属性・イベント一覧
本書で扱った Turbo の主な属性・meta タグ・イベントを、引きやすいようにまとめます。網羅的な一覧と最新の仕様は、公式リファレンスで確認してください。
- Turbo 属性リファレンス: https://turbo.hotwired.dev/reference/attributes
- Turbo イベントリファレンス: https://turbo.hotwired.dev/reference/events
主な data 属性
| 属性 | 役割 | 主な登場章 |
|---|---|---|
data-turbo="false" | 要素や範囲で Turbo を無効化する(内側で "true" に戻せる) | 第7章 |
data-turbo-track="reload" | 追跡対象のアセットが変わったらフルリロードする | 第7章 |
data-turbo-action="advance" | frame の差し替えで URL も更新する | 第14章・第23章 |
data-turbo-frame | リンク/フォームの差し替え先 frame を指定(_top でページ全体) | 第11章 |
data-turbo-permanent | ページが変わっても要素を保持する(id が必要) | 第9章 |
data-turbo-temporary | キャッシュ前に要素を取り除く(プレビューに残さない) | 第9章 |
data-turbo-confirm | 操作前に確認ダイアログを出す | 第10章 |
data-turbo-method | リンクのリクエストメソッドを変える | 第10章 |
data-turbo-stream | GET でも Turbo Streams を受け取る(opt-in) | 第15章・第24章 |
data-turbo-submits-with | 送信中の送信ボタンの文言を差し替える | 第25章 |
主な meta タグ
| meta タグ | 役割 | 主な登場章 |
|---|---|---|
<meta name="turbo-refresh-method" content="morph"> | page refresh を morph で行う(既定 replace) | 第9章 |
<meta name="turbo-refresh-scroll" content="preserve"> | page refresh でスクロール位置を保つ(既定 reset) | 第9章 |
<meta name="turbo-cache-control" content="no-cache"> | ページをキャッシュしない | 第9章 |
<meta name="turbo-cache-control" content="no-preview"> | プレビュー表示だけ止める | 第9章 |
主なイベント(すべて document で発火)
| イベント | タイミング | 主な登場章 |
|---|---|---|
turbo:click | Turbo 有効なリンクをクリックした | 第10章 |
turbo:before-visit | visit を始める直前(preventDefault で中断可) | 第10章 |
turbo:visit | visit を始めた | 第10章 |
turbo:before-render | 新しい body を描画する直前 | 第10章 |
turbo:render | 描画した | 第10章 |
turbo:load | ページ読み込みが完了した(初回と各 visit 後) | 第10章 |
turbo:before-cache | スナップショットを保存する直前 | 第9章 |
turbo:submit-start | フォーム送信が始まった | 第10章・第25章 |
turbo:submit-end | フォーム送信が終わった(detail.success を含む) | 第10章 |
turbo:frame-load | frame の読み込みが完了した | 第29章 |
turbo:frame-render | frame を描画した | 第29章 |
turbo:before-morph-element | 要素を morph する直前(preventDefault でスキップ可) | 第10章 |
turbo:morph | morph が終わった | 第10章 |
値の正確な指定や、ここに載せていない属性・イベントは、必ず公式リファレンスで確認してください。Turbo のバージョンによって追加・変更されることがあります。
付録C Turbo Streams アクション一覧
Turbo Streams の 8 つのアクションと、対応する Rails のヘルパーをまとめます。詳細と最新仕様は、公式リファレンスで確認してください。
- Turbo Streams リファレンス: https://turbo.hotwired.dev/reference/streams
8 つのアクション
| アクション | 何をするか | ヘルパー例 |
|---|---|---|
append | target の末尾に追加する | turbo_stream.append "tasks", @task |
prepend | target の先頭に追加する | turbo_stream.prepend "tasks", @task |
replace | target の要素自体を置き換える | turbo_stream.replace @task |
update | target の中身だけを置き換える | turbo_stream.update "flash", partial: "layouts/flash" |
remove | target を削除する(中身の HTML は不要) | turbo_stream.remove @task |
before | target の直前に挿入する | turbo_stream.before "task_1", @task |
after | target の直後に挿入する | turbo_stream.after "task_1", @task |
refresh | ページの再描画を促す | <turbo-stream action="refresh">(第15章) |
target と targets
target…idを 1 つ指定し、その 1 要素を対象にする。targets… CSS セレクタを指定し、当てはまるすべての要素を対象にする。
送り方の整理
| 送る経路 | 使う場面 | 登場章 |
|---|---|---|
フォーム送信の応答(format.turbo_stream) | 自分の操作への反映 | 第16章 |
コントローラからの broadcast(Turbo::StreamsChannel.broadcast_*_to) | 他ユーザーへの配信 | 第18章 |
モデルの callback(broadcasts_to) | レコードの作成・更新・削除を自動配信 | 第18章 |
ページ内の <turbo-stream> 要素 | 初期表示時に命令を含める | 第15章 |
覚えておく要点
replaceは要素ごと、updateは中身だけ(第15章)。- 1 つのレスポンスに、複数の命令を入れられる(第16章・第17章)。
- 命令の宛先(
id)はdom_idで揃えると、表示側とずれない(第17章)。 - フォーム送信の応答で効く(MIME は
text/vnd.turbo-stream.html、第15章)。GET は既定では受け取らないが、data-turbo-streamで opt-in できる。
ヘルパーの細かい引数や、ここに載せていない使い方は、公式リファレンスと turbo-rails のソースで確認してください。
付録D Stimulus リファレンス
本書で扱った Stimulus の中心要素を、引きやすいようにまとめます。最新の仕様は公式リファレンスで確認してください。
- Stimulus リファレンス: https://stimulus.hotwired.dev/reference/controllers
ライフサイクルのコールバック
| コールバック | タイミング |
|---|---|
initialize() | controller のインスタンスが作られたとき(1 度だけ) |
connect() | 要素に結びついたとき(Turbo の差し替えのたびにも) |
disconnect() | 要素から外れたとき |
connect / disconnect は、Turbo の visit・frame 差し替え・Streams 挿入のたびに呼ばれます(第19章)。初期化と破棄を対にします(第22章)。
Targets
static targets = ["field", "output"]
| 参照 | 意味 |
|---|---|
this.fieldTarget | 最初の field target(単数) |
this.fieldTargets | すべての field target(配列) |
this.hasFieldTarget | field target があるか(真偽) |
fieldTargetConnected(el) | field target が増えたとき |
fieldTargetDisconnected(el) | field target が減ったとき |
HTML 側は data-<identifier>-target="field"(第20章)。
Values
static values = { delay: { type: Number, default: 3000 } }
| 参照 | 意味 |
|---|---|
this.delayValue | 値を読む/書く |
delayValueChanged() | 値が変わったとき |
型は String / Number / Boolean / Array / Object。HTML 側は data-<identifier>-delay-value="..."(複数語は data-...-refresh-interval-value)。第21章。
CSS Classes
static classes = ["hidden"]
this.hiddenClass で読みます。HTML 側は data-<identifier>-hidden-class="..."。クラス名を JavaScript に直書きせず HTML に置けます(第21章)。
Outlets
static outlets = ["list"]
this.listOutlet で、結びついた別 controller のインスタンスを参照します。HTML 側は data-<identifier>-list-outlet="#selector"。Outlet 先の要素が当該 controller である必要があります(Stimulus 3.2 以降、第21章)。
Actions
HTML 側の書式は data-action="イベント->identifier#メソッド"。
- 既定イベントのある要素(
buttonのclick、input/textarea/selectのinputなど)は、イベントを省ける。 click@window->id#methodのように@window/@documentで、その対象のイベントを拾える。- 第20章。
命名規則
- ファイル名
autofocus_controller.js→data-controller="autofocus"。 - サブディレクトリの
/は--、語区切りの_は-。users/list_item_controller.js→users--list-item(第19章)。
ここに載せていない API や、細かい仕様は、公式リファレンスで確認してください。
付録E よくあるエラーと対処
Hotwire でつまずきやすいエラーと、その対処をまとめます。多くは、第29章の観察の手順(Network → Turbo イベント → Stimulus → target → morph)で原因にたどり着けます。
フォームを送っても何も起きない
症状: フォームを送信したのに、画面が変わらず、エラーも出ない。
原因: 失敗時に 422 ではなく 200 でフォームを返している。Turbo は、状態を変えるフォーム送信への 200 のレンダリングを行わず、送信元の URL に留まります(第8章)。
対処: 失敗時は render ..., status: :unprocessable_entity(422)で返す。成功時はリダイレクト(update / destroy は status: :see_other)。
frame が空になる/案内メッセージが出る(Content missing)
症状: frame の中身が消える、または「Content missing」のような案内が出て、Console に例外が出る。
原因: リンク先のレスポンスに、同じ id の <turbo-frame> がない(第11章)。
対処: リンク元とリンク先の両方に、同じ id の frame があるか確認する。dom_id を使い、手書きの id がずれていないかを見る(第17章)。
Turbo Streams の命令が効かない
症状: stream を返しているのに、画面が更新されない。
原因: target が指す id の要素が、画面に存在しない。存在しない id への命令は、静かに何も起こしません(第29章)。
対処: Network タブで stream の中身を見て、target の id が DOM にあるか、Elements タブで確認する。
Stimulus の controller が動かない
症状: data-controller を付けたのに、振る舞いが動かない。
原因: controller 名とファイル名のずれ、ファイルの置き場所の誤り、target / action の名前の不一致(第19章・第20章)。
対処: application.debug = true にして、接続ログが出るか見る(第29章)。出なければ名前と置き場所、出れば target / action の名前を疑う。
戻る・進むで、古い内容や壊れた表示が出る
症状: 戻る操作で、古いフラッシュや、初期化済みのウィジェットが一瞬出る。
原因: キャッシュのプレビューに、消したい要素や、JavaScript で書き換えた DOM が焼き付いている(第9章・第22章)。
対処: 一度きりの要素には data-turbo-temporary を付ける。外部ライブラリは turbo:before-cache で後始末する。プレビューを止めたいページは turbo-cache-control の no-preview。
外部ライブラリが二重に初期化される
症状: 画面を行き来すると、ライブラリが重複して動く、残骸が残る。
原因: connect() で初期化したものを、disconnect() で破棄していない(第22章)。
対処: disconnect() で destroy() し、タイマーやリスナーも片付ける。
リアルタイム更新が届かない
症状: 他のユーザーの操作が、自分の画面に反映されない。
原因: 購読先(turbo_stream_from)と配信先(broadcasts_to / broadcast_*_to)の指す相手がずれている。または、update_all などの一括更新で callback が走っていない(第18章)。
対処: 購読先と配信先が同じ streamable を指しているか確認する。一括更新では broadcast が走らないことに注意する。
解決しないときは、最小の再現コードを作り、第29章の手順で 1 つずつ切り分けてください。
付録F AI に Hotwire コードを依頼するときのプロンプト集
AI に Hotwire のコードを書いてもらうとき、前提を伝えないと、古いやり方や、本書と食い違う実装が返ってくることがあります。前提を添えると、精度が上がります。この付録では、依頼のコツとプロンプトの型を示します。
伝えるべき前提
AI への依頼には、次の前提を添えます。
- Rails と Hotwire のバージョン(例: Rails 8.0、Turbo 8 以降、importmap 構成)。
- フォームの契約(成功は redirect、
update/destroyはstatus: :see_other、失敗はstatus: :unprocessable_entity)。 - 部分更新の方針(一覧の
id、dom_idで宛先を揃える、partial を一覧と stream で共通化)。 - 使わない方針(jsbundling ではなく importmap、状態は HTML に置く、など)。
これらは、本書で繰り返し出てきた約束ごとです。前提として渡すと、AI の出力が本書のやり方に揃います。
プロンプトの型
Rails 8 / Turbo 8 / importmap 構成です。次の前提を守ってコードを書いてください。
- フォームの成功は redirect(update/destroy は status: :see_other)、失敗は status: :unprocessable_entity(422)。
- 部分更新は Turbo Streams。target の id は dom_id で揃える。
- 一覧と Streams で同じ partial を使う。
- JavaScript は Stimulus。状態は HTML の data 属性に置く。
依頼: <ここに作りたいものを書く(例: タスクを作成したら一覧の先頭に追加し、件数も更新する)>
出力: controller の該当アクション、turbo_stream のビュー、必要な partial、関連する Stimulus controller。
レビューの観点を AI に渡す
AI が書いたコードをレビューさせるときは、本書の観点をそのまま使えます。
次の Hotwire コードをレビューしてください。特に次を確認します。
- 失敗時に 422 を返しているか(200 で返していないか)。
- frame / stream の id が dom_id で表示側と揃っているか。
- broadcast の配信先が広すぎないか、秘密情報を含めていないか。
- Stimulus が disconnect で後始末しているか。
- a11y(フォーカス移動、aria-live)を踏まえているか。
注意
- AI の出力は、必ず自分で確認します。とくにバージョン依存の API(morphing 周りなど)は、古い書き方が混じりやすいので、付録B〜Dや公式リファレンスと突き合わせます。
- 動かないときは、付録Eと第29章の手順で切り分けます。AI に丸投げせず、自分が原因を追えることが、結局いちばん速い解決になります。
付録G 完成版サンプルアプリのコード解説
本書を通して育てた Relay の全体像を、ファイルの役割と、対応する章で振り返ります。各部分が「どの章で、なぜそう作ったか」を、まとめて見渡せます。
モデル
| ファイル | 役割 | 章 |
|---|---|---|
app/models/user.rb | ログインユーザー。担当タスク・コメントを持つ | 第5章 |
app/models/project.rb | タスクをまとめる単位 | 第5章 |
app/models/task.rb | 中心リソース。status enum、broadcasts_to | 第5章・第18章 |
app/models/comment.rb / tag.rb / tagging.rb | コメント・タグ(中間モデル含む) | 第5章 |
Task#status の enum(todo / in_progress / done)は、検索・絞り込み・バリデーション・表示の軸になりました(第4章)。
コントローラ
app/controllers/tasks_controller.rb が中心です。
index… 検索・絞り込み(sanitize_sql_like)、ページネーション、一覧スコープ@tasks(第23章・第24章・第17章)。create/update/destroy… 成功は redirect / Turbo Streams、失敗は 422(第8章・第16章)。format.turbo_streamとformat.htmlの両方を持つ。
認可は controller で行います(第31章)。Relay は単一チーム前提なので最小限です。
ビューと partial
| ファイル | 役割 | 章 |
|---|---|---|
app/views/tasks/_task.html.erb | タスク 1 件の表示(turbo_frame_tag) | 第12章 |
app/views/tasks/_tasks.html.erb | 一覧領域(空状態の分岐込み) | 第17章 |
app/views/tasks/_form.html.erb | フォーム(a11y 属性) | 第25章 |
app/views/tasks/*.turbo_stream.erb | create / destroy などの stream 命令 | 第16章 |
app/views/layouts/_flash.html.erb | フラッシュ(#flash / aria-live) | 第27章 |
partial を一覧と stream で共通化したのが、設計の要でした(第12章・第17章)。
Stimulus controller
| ファイル | 役割 | 章 |
|---|---|---|
autofocus_controller.js | 要素にフォーカス(エラーサマリにも再利用) | 第19章・第25章 |
counter_controller.js | 文字数カウンタ | 第20章 |
toast_controller.js | トーストの自動消滅・閉じる(Values の delay) | 第21章・第27章 |
search_controller.js | 入力の debounce 送信 | 第23章 |
infinite_scroll_controller.js | IntersectionObserver で自動読み込み | 第24章 |
dropdown_controller.js / modal_controller.js | ドロップダウン・モーダル | 第26章 |
chart_controller.js など | 外部ライブラリ連携 | 第22章 |
どれも connect / disconnect を対にし、状態は HTML 側に置きました(第6部)。
リアルタイム
turbo_stream_from(購読、ログイン時のみ)とbroadcasts_to/broadcast_*_to(配信)でリアルタイム更新(第18章・第27章)。- 配信範囲と認可に注意(第31章)。
読み進め方
このアプリは、第2部で素の CRUD を作り、第3部以降で 1 つずつ Hotwire 化したものです。各ファイルを見るときは、対応する章に戻ると、「なぜそう書いたか」がたどれます。最初の素の状態から、段階を追って育てたことが、コード全体に表れています。
完成版の Relay のコード一式は、次のリポジトリで公開しています。
手元のコードと突き合わせながら読むと、各章の差分が追いやすくなります。
付録H Hotwire Native ハンズオン(実機ビルド手順)
第9部では、Hotwire Native の考え方を学びました。この付録では、実機でビルドして Relay を表示するまでの手順を、流れに沿って案内します。
実機ビルドには、ネイティブの開発環境が必要です。具体的なコマンドや画面は、Xcode / Android Studio や Hotwire Native のバージョンによって変わります。本付録は流れを示すもので、最新の正確な手順は、必ず公式ドキュメントで確認してください。
- Hotwire Native: https://native.hotwired.dev/
前提
- iOS は Xcode、Android は Android Studio が要ります。
- Relay(本書の Rails アプリ)が起動していて、実機・シミュレータからアクセスできること。シミュレータからは、開発マシンの IP やホスト名で Rails に届く必要があります。
- Web 側(Relay)が、モバイルの画面幅でも使えること(レスポンシブ。第32章)。
手順の流れ
1. ネイティブプロジェクトを用意する
- iOS … Xcode で新規アプリを作り、Hotwire Native の iOS ライブラリ(Swift)を追加します。
- Android … Android Studio で新規アプリを作り、Hotwire Native の Android ライブラリ(Kotlin)を追加します。
ライブラリの追加方法(Swift Package / Gradle の指定)は、公式ドキュメントの手順に従います。
2. Relay の URL を指す
ネイティブアプリの開始 URL を、Relay のトップ(または一覧)に設定します。これだけで、WebView に Relay の Web 画面が表示され、ネイティブのナビゲーションの中で動き始めます(第32章)。まずは、ここまでで「Web がアプリの中で動く」ことを確認します。
3. Path Configuration を置く
URL ごとの見せ方を、Path Configuration(第33章)で指定します。アプリに同梱するか、サーバーから配信します。たとえば、/tasks/new をモーダルで出すルールを書きます。正確なプロパティ名と値は、公式ドキュメントで確認します。
4. Bridge Components を足す(任意)
ネイティブの定位置に部品を置きたい場合、Bridge Components(第34章)を使います。Web 側にブリッジ用の Stimulus controller を、ネイティブ側に対応する component を用意します。ここで大切なのは、ブラウザでも壊れないよう、Web 側にフォールバック(通常の送信ボタンなど)を残すことです(第34章)。
5. Native Screens と認証(任意)
ログインなどをネイティブ画面にする場合(第35章)、ネイティブで認証した結果を WebView のセッションへ受け渡します。cookie やトークンの受け渡しは、安全に設計します(第31章)。具体的な受け渡しの実装は、公式ドキュメントの例を参照します。
配布の注意
ネイティブ部分(native shell・ネイティブ画面・Bridge Components)を変えたら、アプリをビルドし直し、ストアで配信(審査)します。Web 画面の更新はサーバーで即反映できますが、ネイティブ部分はストアを通します(第32章・第35章)。更新サイクルの違いを踏まえ、ネイティブ部分は最小限にします。
まとめ
「Relay の URL を指すだけで Web が動く」ところから始め、必要に応じて Path Configuration・Bridge Components・Native Screens を足す。この順で進めれば、Web-first のまま、無理なくモバイルへ広げられます。詰まったら、考え方は第9部に、最新の手順は公式ドキュメントに戻ってください。
おわりに
本書を最後まで読んでいただき、ありがとうございました。
本書では、チーム向けタスク管理アプリ Relay を、最初の素の CRUD から育てながら、Hotwire を一通り学びました。Turbo Drive でページ遷移を、Turbo Frames で画面の一部を、Turbo Streams で部分更新とリアルタイムを、Stimulus で振る舞いを、Hotwire Native でモバイルへの広がりを。そして、テスト・デバッグ・性能・セキュリティと、保守の観点も見てきました。
繰り返し戻ってきたのは、「HTML over the wire」という一つの考え方でした。サーバーが HTML を返し、ブラウザがそれを賢く反映する。Turbo も Stimulus も Native も、すべてこの考え方の現れです。個々の機能名や属性は忘れても、この軸さえ持っていれば、Hotwire のこれからの変化も読み解けます。
もう一つ、本書が大切にしたのは、「できる」と「保守できる」を分けて考えることでした。Hotwire は強力なので、何でも部分更新で作れてしまいます。だからこそ、「これは frame でやるべきか」「ここは通常の遷移に戻すべきか」を問い続けることが、長く付き合えるアプリにつながります。道具に振り回されず、道具を選ぶ側でいてください。
Hotwire は動き続けます。本書の内容も、いつかは古くなります。そのときは、本書で身につけた考え方を土台に、一次情報で最新を確かめてください。確認日とバージョンを意識する——本書が通して守ってきたこの姿勢が、変化の中であなたを支えてくれるはずです。
それでは、あなたのアプリで、Hotwire を活かしてください。
FjordBootCamp について
本書は、プログラミングスクール FjordBootCamp(フィヨルドブートキャンプ) の教材として作成されました。
FjordBootCamp は、現役エンジニアが運営する日本語のオンラインプログラミングスクールです。未経験からでも学べる Rails エンジニアコースとフロントエンドエンジニアコースがあり、暗記ではなく「自分で考えて学び続ける力」を育てます。本書で繰り返してきた「なぜそうなっているのかを理解する」という姿勢は、Hotwire に限らず、これからの学習すべてで効いてきます。
もっと体系的に、仲間やメンターと一緒に学びたくなったら、ぜひのぞいてみてください。
- 公式サイト: https://bootcamp.fjord.jp/
主要な一次情報
最後に、実務で何度も戻ることになる主要な一次情報を挙げます。
ライセンス
本書の本文・原稿は、MIT License で公開されています。
Copyright (c) 2026 FjordBootCamp