Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

はじめに

FBC Press: Hotwire ― HTML over the wire という設計思想から理解する

本書は、プログラミングスクール FjordBootCamp(フィヨルドブートキャンプ) の教材として作成された、Hotwire の教科書です。Rails の基本を学習済みの読者に向けています。

Hotwire は、ブラウザで動く JavaScript アプリケーションを大きく作る代わりに、サーバーで生成した HTML を活かして、現代的な操作感を実現するための考え方とツール群です。

本書では、思想や生まれた背景を、実装のハンズオンと結びつけて学びます。題材は、チーム向けタスク管理アプリ 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 アプリの形が残る構成です。第3部 以降は、1 つのアプリを段階的に Hotwire 化していきます。

バージョン基準

本書は 2026年10月9日に公式資料とサンプルを再確認しました。Web サンプルの固定バージョンと検証範囲は第5章・付録Gに記載します。Native の最小構成は付録Hで示しますが、実機ビルド済みのサンプルではありません。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」という一つの考え方の現れです。

Hotwire の全体像。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章)で共有します。

Relay を育てる全体像。素の CRUD から各部で機能を足し、基本機能を備えた Relay へ至る本書全体の道筋を示す成長フロー図

第1章では、Hotwire を「HTML over the wire」という設計思想として捉えました。次の第2章では、なぜ今、HTML を送る設計が見直されているのか、その背景を歴史から見ます。

参考資料

第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 を送る部分更新」を、現代的な使い勝手で実現する道具です。

画面更新の歴史。全ページ再読み込み→Ajax→JSON/SPA→HTML over the wire の流れと、各段の利点と代償を並べたタイムライン

JSON+SPA と HTML over the wire の通信比較。wire を流れるもの(JSON か HTML か)と画面を組み立てる場所(ブラウザかサーバーか)の違いを対比

2.5 Hotwire が向くアプリケーション

もちろん、HTML over the wire が、すべてのアプリに最適なわけではありません。向き不向きがあります。

向くのは、サーバーが持つデータを見せ、操作するのが中心のアプリです。一覧、詳細、作成、編集が主な操作で、画面の状態の多くがサーバー側にある。多くの業務アプリや管理画面、コンテンツ中心のサイトが、これに当てはまります。本書で作る Relay も、その典型です。

逆に、ブラウザ側で重い状態を扱うアプリ(描画ツール、表計算など)や、オフラインで動く必要があるアプリは、SPA の方が向きます。この使い分けは、第10部で改めて詳しく扱います。

第2章では、HTML を送る設計が見直されている背景を、歴史からたどりました。次の第3章では、この考え方が、なぜ Rails ととりわけ相性がよいのかを見ます。

参考資料

第3章 なぜ Rails と Hotwire は相性がよいのか

この章のねらい

第2章で、HTML over the wire という考え方を見ました。この考え方は、Rails ととりわけ相性がよいものです。それは偶然ではありません。Hotwire は、Rails の作者たちが生み出したものだからです。

この章では、Rails のどの仕組みが Hotwire とどうつながるのかを整理します。第1部の締めとして、第2部以降の地図にします。

3.1 Rails はもともと HTML を返すフレームワークである

Rails は、登場したときから、サーバーで HTML を組み立てて返すフレームワークでした。コントローラがデータを用意し、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 コントローラと Turbo Streams

Rails は、REST に沿ったコントローラを勧めます。index・show・new・create・edit・update・destroy という、おなじみの 7 つのアクションです。

Hotwire は、この RESTful な作りの上に、素直に乗ります。たとえば、create で作成したら一覧に追加する、destroy で削除したら一覧から消す、といった部分更新は、Turbo Streams で表現できます。普通の RESTful コントローラを、少し拡張するだけで、画面遷移のない更新になります(第5部で扱います)。新しい設計を覚え直すのではなく、いつものコントローラの延長です。

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 へ分けることを、より意識します。
  • コントローラのレスポンスを、HTML だけでなく Turbo Streams でも返せるように考えます。
  • 要素の id を、dom_id で一貫させ、部分更新の宛先にします。

どれも、Rails の作法から大きく外れるものではありません。「いつもの Rails を、部分更新に向く形に少し整える」。それが、Hotwire を使うときの設計の勘所です。本書は、この勘所を、Relay を育てながら身につけていきます。

Rails の仕組みと Hotwire の接続点。MVC/partial/REST/Action Cable が Turbo Drive・Frames/Streams/broadcast へつながることを線で結んだ対応図

第1部はここまでです。Hotwire を設計思想として捉え、その背景と、Rails との相性を見ました。次の第2部では、いよいよ手を動かします。本書を通して育てるサンプルアプリ Relay の仕様を決め、その土台を作ります。

参考資料

演習(第1部): Hotwire の考え方を確かめる

この部ではまだコードを書きません。Hotwire の考え方を、観察と言語化で確かめます。

この部の到達状態

  • Hotwire が向くアプリケーションと、SPA が向くアプリケーションを自分の言葉で比較できる
  • ページ遷移時に「サーバーが HTML を返している」ことを、ブラウザの DevTools で観察できる
  • 本書で育てる Relay の全体像をイメージできる

演習

  1. 既存の Rails アプリや公式デモで、リンク遷移時のネットワーク通信を DevTools で観察する
  2. 同じ操作を JSON API + SPA で作る場合との違いを書き出す
  3. 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 の学習者がつまずかない範囲に収めています。

モデル主な属性役割
Username, email_address, password_digestログインユーザー、担当者、コメント投稿者
Projectname, descriptionタスクをまとめる単位
Taskproject_id, title, description, status, assignee_id, due_on本書の中心リソース
Commenttask_id, user_id, bodyTurbo Streams と broadcast の題材
Tagname検索・絞り込みの題材
Taggingtask_id, tag_idTask と Tag の中間モデル

関連をまとめると次のとおりです。

  • Project は複数の Task を持ちます。
  • Task は複数の Comment を持ちます。
  • Task は Tagging を介して複数の Tag と多対多です。
  • Task は担当者として User を 1 人持ちます(未割り当ても許します)。
  • Comment は投稿者として User を 1 人持ちます。

Relay のモデル関連と主要画面の対応図

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 の主要画面に Turbo Drive、Frames、Streams、Stimulus、Action Cable を対応づけた図。

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 化していきます。

  1. ページ遷移とフォーム送信を Turbo Drive で速く見せる(第3部)
  2. タスクの一覧・詳細・編集を Turbo Frames で部分更新する(第4部)
  3. 作成・更新・削除・コメントを Turbo Streams で画面遷移なしに反映し、リアルタイム更新まで広げる(第5部)
  4. サーバーとのやり取りが要らない振る舞いを Stimulus で足す(第6部)
  5. 検索・ページネーション・モーダル・通知など、実務的な 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タスクフォームの補助、ドロップダウン、文字数、確認 UIStimulus
第7部 UI パターンタスクの検索・絞り込み、ページネーション、フォーム UX、モーダル / タブ / ドロップダウン、通知トースト総合
第8部 保守System Test、デバッグ、N+1、認可とセキュリティ横断
第9部 NativeRelay をネイティブシェルで包むHotwire Native
第10部 選定Relay を題材にアンチパターンと SPA 比較を振り返る採用判断

第7部の章ごとの内訳(どの章で検索を作り、どの章でモーダルを作るか)は、第7部の各章で詳しく示します。

4.8 完成形の操作ストーリー

完成した Relay を、ユーザーの操作の流れで見てみます。これがゴールのイメージです。

  1. ログインすると、プロジェクト一覧が表示されます。リンクをたどってもページ全体は再読み込みされず、画面が一瞬で切り替わります(Turbo Drive)。
  2. プロジェクトを開くと、タスクの一覧が出ます。上部の検索ボックスにキーワードを入れると、一覧だけがその場で絞り込まれ、URL にも条件が反映されます。共有してもリロードしても同じ結果が出ます(Turbo Frames + Stimulus)。
  3. 一覧をスクロールすると、続きのタスクが追加で読み込まれます(ページネーション)。
  4. タスクの行で「編集」を押すと、その行だけがフォームに変わります。入力が不正なら、ページ遷移せずにエラーがその場に表示されます(Turbo Frames + バリデーション UX)。
  5. 「新規作成」を押すとモーダルが開きます。保存すると、モーダルが閉じ、一覧の先頭に新しいタスクが追加され、件数も更新されます(Turbo Streams)。
  6. タスクを開いてコメントを書くと、同じタスクを見ている他のメンバーの画面にも、リアルタイムでコメントが追加されます(Action Cable)。
  7. 操作のたびに、画面の隅にトーストが出て、少し経つと自動で消えます(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 は後の章で作ることだけは覚えておいてください。

参考資料

第5章 Rails アプリを作成する

この章のねらい

第4章で決めた Relay の仕様を、実際の Rails アプリとして作ります。この章のゴールは、Hotwire のカスタマイズを一切していない「素の Rails アプリ」を、通常の CRUD が動く状態まで用意することです。

ここで作る状態が、第3部以降の出発点になります。最初は Rails が標準で有効にする Turbo Drive だけで始めるのは、「普通の Rails アプリのどこを、なぜ Hotwire で置き換えるのか」を後の章で実感するためです。

この章では、特に次の 3 点を固定します。

  1. Rails 8 標準の認証ジェネレータの実行結果
  2. Project / Task の CRUD と、Comment / Tag / Tagging をモデルだけに留める線引き
  3. importmap を前提にした JavaScript 構成

サンプルの固定環境は Ruby 3.3.5、Rails 8.1.3、turbo-rails 2.0.23、stimulus-rails 1.3.4、importmap-rails 2.2.3 です。すべての Rails 8 系で動作確認したという意味ではありません。Gemfile.lock を保存し、更新時はテストを再実行してください。

5.1 Rails アプリの作成

アプリを作成します。

gem install rails -v 8.1.3
rails _8.1.3_ 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 認証の追加

bin/rails generate authentication が作る User、Session、Current、各コントローラやメール周辺を示す図。

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 通常 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

生成はできました。5.5・5.6 の関連・enum・フォーム・ルートの調整まで済ませてから CRUD を確認します。scaffold が生成したビューは、Rails 8 では最初から Turbo に対応した形になっています。この事実は 5.11 で確認します。

5.4 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.5 生成したモデルへ関連を追加する

第4章のモデル構成に沿って、Relay のモデルを作ります。ここでポイントになるのが、CRUD 画面まで作るモデルと、モデルだけ用意するモデルを分けることです。5.3 と 5.4 で生成したファイルへ、以下を反映します。

まず、各モデルの関連を確認しておきます。生成後に、次の関連を各モデルへ書き加えます。

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, validate: true

  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.6 ルートとフォームを整える

config/routes.rb の Rails.application.routes.draw 内に root "tasks#index" を加えます。認証後の戻り先に必要です。タスク一覧の見出しは「タスク」にします。

scaffold の _form.html.erb では、整数の status 欄を次の選択欄に置き換えます。enum へ送るのは "todo" などのキーで、文字列 "0" ではありません。

<%= form.label :status, "ステータス" %>
<%= form.select :status, Task.statuses.keys %>

プロジェクト・担当者の ID 入力も collection_select に置き換えます。後続章とラベルを揃え、タイトルは「タイトル」、送信ボタンは「保存」にします。完成したフォームは第25章に掲載します。

<%= form.label :project_id, "プロジェクト" %>
<%= form.collection_select :project_id, Project.order(:name), :id, :name %>
<%= form.label :assignee_id, "担当者" %>
<%= form.collection_select :assignee_id, User.order(:name), :id, :name, include_blank: "未割り当て" %>

レイアウトには、authenticated? で判定したログアウトボタンを追加します。Rails 標準認証に current_user ヘルパーはありません。

<% if authenticated? %>
  <%= button_to "ログアウト", session_path, method: :delete %>
<% end %>
<div id="flash" role="status"><%= render "layouts/flash" %></div>

scaffold の各ページにある notice の表示は削除し、フラッシュをレイアウトに集約します。app/views/layouts/_flash.html.erb を作成します。

<% flash.each do |type, message| %>
  <div class="flash <%= type %>" data-turbo-temporary><%= message %></div>
<% end %>

一覧に置く新規フォームのため、後の章で index を書き換える際も @task = Task.new を残します。

5.7 seed データ

検索やページネーションを後の章で体感するには、ある程度の件数が必要です。seed データを用意します。ログイン用のユーザーも、ここで作ります。

seed は、何度実行しても件数が増殖しないように書きます。先頭でドメインデータを消してから作り直し、ログイン用のユーザーは find_or_create_by! で重複を避けます。既存ユーザーのパスワードは変更しません。すでに同じメールのユーザーがいる場合は、そのパスワードでログインします。乱数・日付・採番 ID は実行ごとに変わるため、データの完全再現を保証する 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.8 認証付き System Test の準備

認証ジェネレータを使うと、未ログインのテストはログイン画面へ転送されます。test/fixtures/users.yml の各ユーザーに name を追加し、パスワードのダイジェストを BCrypt::Password.create("password") で作ってください。

共通基底クラスでログインと dom_id を用意します。生成済みのファイルを次の内容へ置き換えます。

test/application_system_test_case.rb

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  include ActionView::RecordIdentifier
  driven_by :selenium, using: :headless_chrome, screen_size: [1200, 1000]

  setup do
    visit new_session_path
    fill_in "email_address", with: users(:one).email_address
    fill_in "password", with: "password"
    click_on "Sign in"
    assert_current_path root_path
  end
end

最初の確認には次のテストを 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

この基底クラスの setup は、各テストの setup より前に実行されます。個別テストにログインを重複して書く必要はありません。第5章の時点で一覧見出しも「タスク」に変更します。実行には Chrome が必要です。

bin/rails test:system

scaffold が作るリクエストテストにも setup 内で sign_in_as users(:one) を追加します。これは認証ジェネレータの SessionTestHelper を使うサーバー側のログインです。ブラウザのログインとは別の方法です。

5.9 最初の動作確認

ここまでの状態を、ブラウザで通して確認します。

  1. bin/rails server でサーバーを起動する
  2. http://localhost:3000/session/new を開き、alice@example.com / password でログインする
  3. http://localhost:3000/projects でプロジェクト一覧を見る
  4. プロジェクトを作成・編集・削除する
  5. タスクを作成し、無効な入力(タイトル空)でバリデーションエラーが出ることを確認する

この時点では、まだ Hotwire のカスタマイズを何もしていません。それでも、画面遷移やフォーム送信は動きます。

5.10 各部のハンズオンで使うテスト方針

本書では、主要な操作に System Test を用意し、各章では確認すべき振る舞いを示します。すべての章にテストコードを掲載する構成ではありません。たとえば、インライン編集(第12章)、Turbo Streams での作成・削除(第16章)、検索(第23章)などです。

これらの小さなテストは、第8部「Hotwire アプリを保守する」で、テスト戦略として束ね直します。章の途中の UI と完成版ではラベルや構造が変わります。第28章で完成版のテストへ置き換えます。

5.11 通常 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 は後で作る」という線引きは、後の章で繰り返し前提になります。

参考資料

第6章 Hotwire の標準構成を確認する

この章のねらい

第5章で作った Relay には、すでに Hotwire 一式が入っています。この章では、それらが「どのファイルにあり、どう読み込まれているか」を把握します。

仕組みを先に押さえておくと、第3部以降で「なぜこの 1 行で Turbo が動くのか」「Stimulus のコントローラはなぜ自動で登録されるのか」と迷わずに済みます。あわせて、本書が JavaScript 構成として importmap を主軸にする理由と、その限界も確認します。

6.1 turbo-rails

Turbo は turbo-rails という gem で入っています。この gem は、2 つのものをまとめて提供します。

  1. JavaScript パッケージ @hotwired/turbo-rails。これを import すると、Turbo の JavaScript(Turbo Drive / Frames / Streams)が効きます。
  2. サーバー側の 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

application.js から importmap.rb の pin を通じて Turbo と Stimulus が読み込まれる経路を示す図。

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部では、すでに動いているこの仕組みを、正面から読み解いていきます。

参考資料

ハンズオン(第2部): サンプルアプリの土台を作る

このハンズオンでは、本書を通して育てるタスク管理アプリ Relay の土台を作ります。ここではまだ Hotwire のカスタマイズはしません。通常の Rails CRUD が動き、以降の章で段階的に Hotwire 化できる状態を目指します。

各部のハンズオンは「到達状態 → 作る・変える → 完成条件 → つまずきやすい点 → Relay の現在地」の順で進めます。

この部の到達状態

このハンズオンを終えると、次の状態になります。

  • Rails 8.1.3で Relay が起動する
  • ログイン・ログアウトができる(Rails 標準の認証機能)
  • Project と Task を一覧・詳細・作成・編集・削除できる
  • Comment、Tag、Tagging はモデルと migration だけ用意してある(UI は後の部で作る)
  • seed データが入っていて、一覧に十分な件数が並ぶ
  • System Test を書く準備ができている

作る・変える

順番に進めます。各手順は「変更前 → 変更後 → 動作確認」で確認します。

  1. アプリを作成する
    • Rails 8.1.3で新規アプリを作成します(importmap を使う標準構成)。
  2. 認証を追加する
    • Rails 標準の認証ジェネレータでログイン機能を入れます。
    • 生成された User は email_address と password_digest だけを持つので、name を追加します。
    • 認証用の Session モデルも生成されますが、これは認証インフラなので Relay のドメインモデルとは区別します。
  3. モデルを作成する
    • Project / Task / Comment / Tag / Tagging を用意します(属性は第4章のモデル構成表のとおり)。
    • Task#status は todo / in_progress / done の enum にします。
  4. 通常 CRUD を生成する
    • Project と Task の一覧・詳細・作成・編集・削除を作ります。
    • Comment / Tag / Tagging の UI はここでは作りません(コメントは第16章以降、タグは第23章以降)。
  5. seed データを入れる
    • プロジェクト 3 件、タスク約 150 件、タグ数種、コメントを散らして投入します。
    • ページネーションや検索を後の部で体感できるだけの件数にします。
  6. 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 として理解します。フォーム送信には別のライフサイクルがあり、その応答から visit へ進む場合があります。Frame の読み込みや Stream の適用も、通常の Drive visit と区別します。

7.1 通常のページ遷移

まず、Turbo がなかった頃のページ遷移を思い出します。

従来の Rails では、リンクをクリックするたびに、ブラウザは次のことをしていました。

  1. サーバーに新しいページを要求する
  2. 返ってきた HTML で、ページ全体を捨てて作り直す
  3. 新しい文書で CSS と JavaScript を処理する(HTTP キャッシュを利用できる場合は再転送しない)

この作り直しには、いくつかの問題があります。毎回ページ全体を再構築するため、表示が途切れる場合があります。戻る操作ではブラウザの bfcache やスクロール復元が働くこともあり、常に白くなるわけではありません。そして、JavaScript で保持していた状態(開いていたメニューなど)も、すべてリセットされます。

ページの大部分(ヘッダー、サイドバー、CSS、JavaScript)は前のページと同じなのに、毎回まるごと作り直しているわけです。ここに無駄があります。

7.2 Turbo Drive の visit と body 差し替え

リンククリックから fetch、HTML 応答、body 差し替えまでの流れを示すシーケンス図。

Turbo Drive は、この無駄をなくします。リンクのクリックを横取りし、ページ全体の作り直しではなく、必要な部分だけの差し替えに変えます。

Turbo Drive がリンク遷移で行うことを、本書では visit と呼びます。visit の中身は次のとおりです。

  1. リンク先の HTML を、バックグラウンドで取得する(fetch)
  2. 取得した HTML の <body> で、いまの <body> を差し替える
  3. <head> は、まるごと捨てずにマージする(7.3 で扱います)

既存のページを表示しながら次の HTML を取得し、表示の途切れを減らします。<head> で読み込み済みの CSS や JavaScript も、読み込み直されません。だから、JavaScript の実行環境を毎回ゼロから立て直さずに済みます。これが、Relay のリンク遷移が速い理由です。

なお、visit には 2 種類あります。リンクのクリックなど新しいページへ進むときの visit(application visit)と、ブラウザの戻る・進むで起きる visit(restoration visit)です。restoration visit はスクロール位置やキャッシュと関わります。この章では主に application visit を扱い、restoration visit とキャッシュは第9章で扱います。

なお、速くなるのは「読み込み済みの資産を読み直さない」からであって、<body> の中身は差し替わります。開いていたメニューのような body 内の UI 状態は、特別な指定をしない限りリセットされます(状態を保つ方法は第9章で扱います)。

Turbo Drive のページ置換。通常遷移は全体を作り直すが、visit は body だけを差し替える(head はマージ)

大切なのは、サーバー側は何も特別なことをしていない点です。Relay のコントローラは、通常どおり 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 は、同一オリジンの対象リンク・フォームを自動で扱います。外部オリジン、download 属性、対象外拡張子や明示的な無効化などは除外されます。ほとんどの場面ではそれで問題ありませんが、ときには 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 へ進む流れ、そして成功と失敗でコントローラが返すべきものが決まっていることを見ます。

参考資料

第8章 リンクとフォーム送信の仕組み

この章のねらい

第7章では、リンクのクリックが visit になることを見ました。この章では、フォーム送信の応答からページ表示を更新する流れことを理解します。

そして、フォーム送信ではコントローラが返す内容に約束ごとがあります。成功したときと失敗したときで、返すべきものが決まっているのです。この約束ごとは、第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 へ進むかエラー HTML を表示します。Turbo がフォーム送信を横取りし、バックグラウンドで送信し、返ってきた結果を画面に反映します。

ここで問題になるのが、「送信のあと、何を返すか」です。GET のリンクなら、行き先の HTML を返せば済みます。しかし、状態を変える送信では、成功する場合と失敗する場合があります。この 2 つで、返すべきものが変わります。

8.3 成功時は redirect

送信が成功したとき、コントローラは リダイレクトを返します。たとえばタスクを作成できたら、作成したタスクの詳細ページへリダイレクトします。

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 を差し替える、という前提で動いているからです。

フォーム送信の契約。成功=redirect(update/destroy は 303)、失敗=422 render、200 は差し替わらず送信元 URL に留まる、という分岐を示すフローチャート

逆に、この契約を外すと正しく動きません。よくあるつまずきは、失敗時に 422 ではなく 200 でフォームを返してしまうことです。

Turbo Drive の非 GET フォーム送信に対して、リダイレクトなしの 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 "タイトル", with: "最初のタスク"
    select @project.name, from: "プロジェクト"
    click_on "保存"

    assert_text "Task was successfully created"
  end

  test "タイトルが空だと同じ画面にエラーが出る" do
    visit new_task_path
    fill_in "タイトル", with: ""
    select @project.name, from: "プロジェクト"
    click_on "保存"

    assert_text "prohibited this task from being saved"
  end
end

1 つ目は成功のケースです。リダイレクトが起き、成功メッセージが表示されることを確認します。2 つ目は失敗のケースです。422 でフォームが差し替わり、エラー表示が出ることを確認します。ページ遷移していないことが、この契約の肝です。

項目とボタンは第5章で日本語へ揃えています。第16章以降は成功メッセージとエラー表示も変わるので、第28章の完成版テストへ置き換えます。

実行します。

bin/rails test:system

第8章では、フォーム送信の応答に応じて画面を更新し、成功は redirect、失敗は 422 という契約があることを見ました。次の 第9章 では、visit を速く見せるキャッシュと、Turbo 8 の morphing を扱います。

8.7 契約の適用範囲

200 HTML をそのまま成功応答にしない、という制約は Drive の非 GET 送信についてです。Frame は対応する frame を含む 200 HTML を描画でき、200 の Turbo Stream も処理されます。失敗を成功と区別するため、どの経路でもバリデーション失敗には 422 を返します。作成時も 303 を明示すると意図が揃います。

System Test のエラー文表示だけでは HTTP ステータスを検証できません。リクエストテストでは認証後に無効な入力を POST し、assert_response :unprocessable_entity を使います。

参考資料

第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)をしたとき、利用可能なスナップショットがあれば復元します。キャッシュがなければサーバーから取得します。restoration visit では、復元後に必ず最新 HTML を取得するわけではありません。

従来のページ遷移では、戻るときも毎回サーバーへ取りに行くことがありました。Turbo は、一度見たページをスナップショットで持っておくことで、戻る操作を速くします。

9.2 preview 表示

ページ離脱時のスナップショット保存と、リンクで再訪したときのプレビューから最新差し替えまでの流れを示す図。

snapshot cache は、戻る操作だけでなく、前に見たページへ進むときにも使われます。これが preview(プレビュー)です。

リンクなどによる application 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 が呼ばれます。要素が残る morph では再接続されない場合があります。

ここで注意が要ります。スナップショットは「その瞬間の 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 を比べ、変わった部分だけを書き換えます。全体を捨てて作り直すのではなく、差分を当てる形です。これにより、再利用できる要素のフォーカスなどを維持しやすくなります。ただし未保存の入力が必ず守られるわけではありません。スクロールの維持には、別に turbo-refresh-scroll="preserve" の設定が必要です。

たとえば Relay のタスクボードで、1 件のステータスが変わったとき。body 全体の差し替えだと、見ていた位置やフォーカスが飛びます。morph は新しい HTML に合わせて必要な箇所を更新します。保持対象の要素と入力保護、スクロールの設定は別に設計します。

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>

通常のページ遷移では、旧ページと新ページの双方に、同じ一意の id と data-turbo-permanent を持つ要素がある場合に保持されます。新ページに対応する要素がない場合は保持されません。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部を締めます。

9.11 バージョンと更新の範囲

refresh stream の method / scroll 属性は Turbo 8.0.21 以降の機能です。Turbo 8 の初期版では、head の meta タグで指定します。バージョンは Gem の番号と JavaScript の番号を区別して確認します。

data-turbo-track="reload" は、head の追跡対象タグの HTML(指紋付き URL や importmap の内容など)を比較します。同じ URL の CSS ファイル本文を比較する仕組みではありません。javascript_importmap_tags が生成する importmap も追跡対象です。

morph や update で Stimulus のルート要素が残れば、そのコントローラは再接続されません。接続と切断は DOM 上の接続状態で決まり、サーバーから応答を受けた回数とは一致しません。

参考資料

第10章 Turbo Drive のイベントと制御

この章のねらい

ここまでで、Turbo Drive が visit でページを差し替える様子を見てきました。多くの場合、Turbo は自動で動くので、こちらから何かする必要はありません。

しかし、ときには visit の途中に割り込みたい場面があります。「削除の前に確認したい」「遷移に時間がかかるあいだローディングを出したい」「何が起きているかログで追いたい」といった場面です。

この章では、そのために使う Turbo のイベントと、いくつかの制御方法を見ます。これは第3部の締めであり、第8部(デバッグ)の入口にもなります。

10.1 visit ライフサイクルの主要イベント

Turbo の visit 中に発火する主要イベントの順序と、フォームや morph のイベントを示すタイムライン。

visit は、一瞬で終わるように見えて、内部ではいくつかの段階を踏んでいます。Turbo は、その節目ごとにイベントを発火します。リンクのクリックから画面表示までの主な流れは、次のとおりです。

  • turbo:click — Turbo が有効なリンクをクリックした
  • turbo:before-visit — visit を始める直前
  • turbo:visit — visit を始めた
  • turbo:before-render — 新しい body を描画する直前
  • turbo:render — 描画した
  • turbo:load — ページの読み込みが完了した(初回と、各 visit のあと)

これらのページイベントの多くは <html>、送信イベントは <form>、フレームのイベントは <turbo-frame> から発火し、バブルします。document にリスナーを置けば、それらをまとめて観察できます。発火元は event.target で確認します。必要な段階に合わせてイベントを購読すれば、その瞬間に処理を差し込めます。

ここで思い出してほしいのが、第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() を呼ぶと、application visit を中断できます。ただし戻る・進むによる restoration 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部 では、ページの一部を独立したナビゲーション領域として扱う Turbo Frames に進みます。

参考資料

ハンズオン(第3部): 通常の CRUD を Turbo Drive で動かす

第2部で作った素の CRUD に対して、Turbo Drive の挙動を契約どおりに整えます。

この部の到達状態

  • リンク遷移が visit(body 差し替え+ head マージ)として高速に動く
  • フォーム送信が「成功は redirect、失敗は 422 再描画」に統一されている
  • data-turbo-track="reload" で、アセット更新時だけフルリロードがかかる
  • progress bar が出る
  • プロジェクト削除前に data-turbo-confirm で確認が出る

作る・変える

  1. コントローラのステータスコードを契約どおりに整える(update / destroy は status: :see_other、失敗は 422)
  2. 確認ダイアログとローディング表示を足す
  3. キャッシュとプレビューの挙動を観察する

完成条件

  • 無効な 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 は独立した小さなナビゲーション領域である」です。第3部の visit が <body> 全体の差し替えなら、Turbo Frame は <turbo-frame> 単位の差し替えです。frame 内のリンクやフォームは、ページ全体ではなく frame の中だけを差し替えます。

11.1 Turbo Frames とは

第3部で見た visit は、ページ全体が対象でした。リンクをクリックすると、<body> がまるごと差し替わります。

これは多くの場面で便利ですが、行きすぎることもあります。たとえば、タスク一覧で 1 件を編集したいだけなのに、ページ全体が切り替わると、ヘッダーもサイドバーも他の行も、すべて描画し直されます。本当は 1 行だけ変えたいのに、です。

Turbo Frames は、ページの一部を <turbo-frame> という枠で囲み、その枠を独立したナビゲーション領域にします。枠の中のリンクやフォームは、ページ全体ではなく、その枠の中だけを差し替えます。枠の外(ヘッダーやサイドバー)は、まったく動きません。

第3部の visit を「<body> 全体の差し替え」とすれば、Turbo Frames は「<turbo-frame> 単位の差し替え」です。似た操作感ですが、Frame の読み込みは通常の Drive 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 がなければなりません。

Turbo Frame の id 一致。frame 内リンクが、レスポンスから同じ id の turbo-frame だけを抽出して差し替える仕組みと、id 不一致時の Content missing を示す構造図

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、_top での差し替え先の違いを示す図。

ここまでは、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 を「独立した小さなナビゲーション領域」として理解し、id の一致というルールを押さえました。次の第12章では、Relay の一覧・詳細・編集フォームを frame 化し、インライン編集を作ります。

参考資料

第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 インライン編集

タスク行の frame が表示と編集フォームを、保存・キャンセル・失敗で差し替え合う流れを示す図。

いよいよ編集です。編集画面(edit)のフォームを、同じ id の frame で囲みます。

app/views/tasks/edit.html.erb

<%= turbo_frame_tag @task do %>
  <%= render "form", task: @task %>
<% end %>

これで準備が整いました。動きを追ってみます。

  1. 一覧の行(frame task_1)の中の「編集」リンクをクリックする
  2. Turbo は edit_task_path を取得し、その中から id="task_1" の frame を探す
  3. 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 "タイトル", with: "編集後のタイトル"
    click_on "保存"
  end

  within "##{dom_id(task)}" do
    assert_text "編集後のタイトル"
    assert_no_field "タイトル"
  end
end

within "##{dom_id(task)}" で、対象タスクの frame の中だけに操作を絞っています。frame の中で編集リンクを押し、フォームを書き換え、保存します。

保存後の確認では、2 つを見ます。更新後のタイトルが表示されていること(assert_text)と、編集フォームの入力欄が消えていること(assert_no_field "タイトル")です。タイトルの文字列だけを確認すると、編集フォームが残ったまま(=保存に失敗して差し替わっていない)でも、入力した値でテストが通ってしまいます。「フォームが消えて表示に戻った」ことまで確かめることで、frame が表示用の _task に差し替わったことを保証できます。

この確認だけではフルリロードがないことまでは証明できません。第28章の完成版テストでは JavaScript の印を残して、ページ全体が再読み込みされないことも確認します。

第12章では、id を揃えた partial を軸に、ページ遷移のないインライン編集を作りました。次の第13章では、src を使った遅延読み込みと、サイドバー・タブといった画面分割のパターンを学びます。

12.7 削除と詳細へのリンク

第16章で使う削除ボタンも _task の frame 内へ置きます。タイトルからコメント付きの詳細ページへ進むリンクには _top を指定し、編集リンクは行内のままにします。

<%= link_to task.title, task_path(task), data: { turbo_frame: "_top" } %>
<%= button_to "削除", task_path(task), method: :delete,
      data: { turbo_confirm: "削除しますか?" } %>

テストで削除するときは accept_confirm { click_on "削除" } を使います。

参考資料

第13章 遅延読み込みと独立したナビゲーション

この章のねらい

第12章では、frame の中身を「リンクやフォームの操作」で差し替えました。この章では、もう 1 つの差し替えのきっかけを学びます。src による遅延読み込み(lazy loading)です。

frame に src を与えると、その frame は自分で中身を取りに行きます。これを使うと、重い部分を後回しで読み込んだり、サイドバーやタブのように画面を分割したりできます。第12章の終わりで触れた「外枠の中に frame を置く」構成を、ここで実際に作ります。

13.1 lazy loading

src と loading=lazy で可視になってから取得し、skeleton が本体に差し替わる流れを示す図。

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 サイドバー詳細

detail frame の中に task_1 frame が入る入れ子構造を示す図。

第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 %>
  <%# タイトルは更新対象の _task 内に表示します。 %>
  <%= 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 の描画方式と frame の再読み込みは分けて考えます。src と refresh="morph" を持つ frame は、page morph に伴う再取得の対象になります。frame を通常の方法で置換すると、要素の状態やフォーカスが失われる場合があります。

これを避けたいときに、frame へ refresh="morph" を付けます。

<%= turbo_frame_tag "project_tasks", src: tasks_panel_project_path(@project), refresh: :morph do %>
  ...
<% end %>

refresh: :morph を付けると、page refresh と明示的な reload() による frame の再読み込みが、第9章で見た morph(差分の適用)で行われます。対応する要素を再利用しますが、未保存の入力を必ず保持する保証ではありません。通常のフレーム内リンク遷移を、すべて morph に変える指定ではありません。

これは、第18章のリアルタイム更新と組み合わせると効きます。サーバーが「このページを refresh してください」という配信(broadcast refresh)を全員へ送ると、各自の frame が morph で最新化され、要素を再利用しやすくなります。ただし編集中の値が必ず守られる保証ではないため、完成版では第18章の更新通知方式を使います。

第13章では、src による遅延読み込みと、タブ・サイドバーといった画面分割を作りました。frame は入れ子にでき、組み合わせると複雑な画面も組めます。だからこそ、次の第14章では「使いすぎたときにどうなるか」と、Streams や通常遷移へ切り替える判断を扱います。

13.7 この章の別案を混ぜない

13.1 の src による一覧、13.3 のクリックによる遅延表示、13.4 のサイドバーは画面構成の別案です。第16章へ進むときは第12章の行一覧と show の render @task に戻します。サイドバーと行一覧に同じ _task を重ねると DOM ID が重複します。

13.3 のルートは resources :projects の member に get :overview / get :tasklist を追加します。両アクションで @project = Project.find(params[:id]) を取得し、tasklist のビューは tab_content frame 内で render @project.tasks を行います。完成版の配置は第26章に示します。

skeleton の CSS も用意します。

.skeleton { padding: 1rem; background: #eee; }
.skeleton-row { height: 1rem; margin-block: .5rem; background: #ccc; }

参考資料

第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 コントローラが frame 分岐だらけになる兆候

frame を増やすと、コントローラ側にも影響が出ます。「通常のリクエストのときはページ全体、frame からのリクエストのときは frame の中身だけ」と返し分けたくなり、リクエストの種類で分岐するコードが増えていきます。

コントローラのアクションが、リクエストが 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 通常遷移に戻す判断

独立した部分更新の要否と更新箇所数で、通常遷移・Frame・Streams を選ぶフローチャート。

もう 1 つの判断は、逆方向です。そもそも部分更新が要らないなら、frame をやめて通常の遷移に戻す、という判断です。

frame は、「ページの一部を、周りと独立して差し替えたい」ときに価値があります。逆に、ページ全体が変わるような画面遷移(一覧から詳細へ完全に移動する、など)では、frame の独立性は要りません。それなのに frame で作ると、14.2 の URL のズレや、14.1 の入れ子の複雑さだけを抱え込みます。

「URL の問題に悩んでいる」「frame の入れ子と格闘している」けれど、その画面は本当は普通のページでよかった、という場合があります。そのときは、frame を外し、第3部で見た Turbo Drive の通常遷移に戻すのが正解です。Turbo Drive なら、ページ遷移は十分速く、URL も素直に変わります。

判断の基準は、「できるか」ではなく「読みやすく保てるか」です。frame でできるとしても、通常遷移の方が素直なら、そちらを選びます。

第4部では、Turbo Frames を「独立した小さなナビゲーション領域」として学び、その使いどころと、使いすぎの見切りまで見ました。14.4 で触れたとおり、複数箇所を同時に更新したくなったら、次の道具が要ります。第5部では、その Turbo Streams を学びます。

参考資料

ハンズオン(第4部): インライン編集できる一覧を作る

タスクの一覧・詳細・編集フォームを Turbo Frames で分割し、ページの一部だけを更新できるようにします。

この部の到達状態

  • タスク一覧の各行が frame になっている
  • 行内でインライン編集でき、キャンセル導線もある
  • プロジェクトのタスク一覧が src で遅延読み込みされ、読み込み中は skeleton が出る
  • ページ内タブが frame の遅延読み込みで切り替わる

作る・変える

  1. 行・詳細・フォームを共有 partial にまとめる
  2. それらを turbo_frame_tag で frame 化する(id の一致に注意)
  3. サイドバー詳細とタブをリンクで切り替える(プロジェクト一覧の 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> 自体は画面に表示されず、命令として処理されると消えます。

Turbo Streams のレスポンスと DOM 更新。turbo-stream の action・target・template の構造と、それが対象 id の DOM をどう操作するかを示す構造図

そして、命令は1 つのレスポンスに複数入れられます。ここが frame との決定的な違いです。frame が 1 か所しか差し替えられなかったのに対し、Streams は離れた複数の場所を、それぞれ別の action で同時に操作できます。第14章で frame では応えられなかった「行を消し、件数を更新し、フラッシュを出す」が、Streams なら 3 つの命令で実現できます(第17章で実装します)。

15.2 8 つの action

Turbo Streams には、8 つの action があります。

action何をするか
appendtarget の末尾に追加する
prependtarget の先頭に追加する
replacetarget の要素自体を置き換える
updatetarget の中身だけを置き換える
removetarget を削除する(中身の HTML は不要)
beforetarget の直前に挿入する
aftertarget の直後に挿入する
refreshページの再描画を促す(15.6 で扱う)

replace と update の違いに注意してください。replace は target の要素ごと差し替えます。update は target の要素は残し、その中身だけを差し替えます。タスクの行ごと入れ替えたいなら replace、行の中の一部分だけ変えたいなら update、という使い分けです。

8 つのアクションの図解。append/prepend/replace/update/remove/before/after/refresh が対象要素に与える効果を、ビフォー/アフターで並べた比較図

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 を用意します。コントローラ側では、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 で動かします。

15.9 refresh の属性とバージョン

refresh 自体は Turbo 8 の機能です。個々の stream に指定する method="morph" と scroll="preserve" は Turbo 8.0.21 以降が対象です。それ以前は head の meta タグで設定します(第9章)。

参考資料

第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(抜粋)

<%# flash は第5章でレイアウトに設置済みです。ここへ重ねて置きません。 %>

<div id="new_task_form">
  <%= render "form", task: @task, context: "inline" %>
</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, context: "inline" %>
<% 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 を更新する

1 レスポンスに含めた 3 つの turbo-stream が、一覧・フォーム・フラッシュの 3 か所を同時に更新する図。

操作の結果を、フラッシュで知らせたいこともあります。一覧の入れ物に id="flash" を用意してあるので、ここを更新する命令を足します。

まず、コントローラでフラッシュを設定します。ここで注意が要ります。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 送信元に応じてエラーを戻す

一覧内フォームと /tasks/new の独立したページでは、差し替え先が違います。フォームの先頭に次を加え、一覧からは context: "inline" を渡します。単独ページでは HTML の成功リダイレクトを使うため、フォーム送信の Turbo を無効にしています。ページのリンク遷移まで無効にする設定ではありません。

<% context = local_assigns.fetch(:context, "page") %>
<%= form_with model: task, data: { turbo: context != "page" } do |form| %>
  <%= hidden_field_tag :form_context, context %>
  <%# 既存の入力欄と保存ボタンをここへ残す %>
<% end %>

編集フレームの render "form" には context: "edit" を渡します。以下で、既存の create / update / destroy を置き換えます。set_task と task_params は scaffold のまま残します。

def create
  @task = Task.new(task_params)
  if @task.save
    respond_to do |format|
      format.turbo_stream { flash.now[:notice] = "タスクを作成しました。" }
      format.html { redirect_to @task, notice: "タスクを作成しました。", status: :see_other }
    end
  elsif params[:form_context] == "inline" && request.format.turbo_stream?
    render turbo_stream: turbo_stream.update("new_task_form", partial: "tasks/form",
      locals: { task: @task, context: "inline" }), status: :unprocessable_entity
  else
    render :new, formats: [:html], status: :unprocessable_entity
  end
end

def update
  if @task.update(task_params)
    respond_to do |format|
      format.turbo_stream { flash.now[:notice] = "タスクを更新しました。" }
      format.html { redirect_to @task, notice: "タスクを更新しました。", status: :see_other }
    end
  else
    render :edit, formats: [:html], status: :unprocessable_entity
  end
end

def destroy
  @task.destroy!
  respond_to do |format|
    format.turbo_stream { flash.now[:notice] = "タスクを削除しました。" }
    format.html { redirect_to tasks_path, notice: "タスクを削除しました。", status: :see_other }
  end
end

update と destroy の stream テンプレートにも turbo_stream.update "flash", partial: "layouts/flash" を追加します。作成の成功時に単に stream を返すだけでは、単独の new ページに #tasks がないので表示は更新されません。HTML 経路はこの違いを扱うために必要です。

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 "タイトル", with: "新しいタスク"
    select @project.name, from: "プロジェクト"
    click_on "保存"

    within "#tasks" do
      assert_text "新しいタスク"
    end
  end

  test "タイトルが空だとフォームにエラーが出る" do
    visit tasks_path
    fill_in "タイトル", with: ""
    select @project.name, from: "プロジェクト"
    click_on "保存"

    within "#new_task_form" do
      assert_selector "input[name='task[title]']"
      assert_selector "[role=alert]"
    end
  end

  test "削除すると一覧から消える" do
    task = @project.tasks.create!(title: "消されるタスク")
    visit tasks_path

    within "##{dom_id(task)}" do
      accept_confirm { click_on "削除" }
    end

    assert_no_text "消されるタスク"
  end
end

このテストは表示内容を確認します。HTTP 422 はリクエストテストで検証します。フルリロードがないことや先頭への追加まで保証するには、第28章の完成版テストのように JavaScript の印と先頭要素を追加で検証します。作成成功は #tasks の中に出ること、失敗は #new_task_form の中にエラーが出ること、削除は一覧から消えることを、それぞれ見ています。

第16章では、CRUD を Turbo Streams で動かし、作成時に複数箇所を同時更新できることを見ました。次の第17章では、その「複数箇所の同時更新」を、件数・空状態・partial 共通化・id 設計の観点で深めます。

16.7 コメントの UI を追加する

config/routes.rb の resources :tasks に resources :comments, only: %i[create destroy], module: :tasks をネストします。TasksController#show に @comment = Comment.new を追加します。

app/controllers/tasks/comments_controller.rb

# 第16章: コメントの追加・削除を Turbo Streams で
class Tasks::CommentsController < ApplicationController
  before_action :set_task

  def create
    @comment = @task.comments.build(comment_params.merge(user: Current.user))
    if @comment.save
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_to @task }
      end
    else
      respond_to do |format|
        format.turbo_stream do
          render turbo_stream: turbo_stream.update(helpers.dom_id(@task, :comment_form),
            partial: "comments/form", locals: { task: @task, comment: @comment }), status: :unprocessable_entity
        end
        format.html { render "tasks/show", status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @comment = @task.comments.find(params[:id])
    @comment.destroy!
    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.remove(@comment) }
      format.html { redirect_to @task }
    end
  end

  private

  def set_task
    @task = Task.find(params[:task_id])
  end

  def comment_params
    params.expect(comment: [ :body ])
  end
end

app/views/comments/_form.html.erb

<%= form_with model: [task, comment] do |form| %>
  <% if comment.errors.any? %>
    <div role="alert" tabindex="-1" data-controller="autofocus"><%= comment.errors.full_messages.to_sentence %></div>
  <% end %>
  <%= form.label :body, "コメント本文" %>
  <%= form.text_area :body %>
  <%= form.submit "コメント", data: { turbo_submits_with: "送信中…" } %>
<% end %>

app/views/comments/_comment.html.erb

<div id="<%= dom_id(comment) %>" class="comment">
  <strong><%= comment.user.name %></strong>
  <span><%= comment.body %></span>
  <%= button_to "削除", task_comment_path(comment.task, comment), method: :delete %>
</div>

app/views/tasks/comments/create.turbo_stream.erb

<%= turbo_stream.append dom_id(@task, :comments), partial: "comments/comment", locals: { comment: @comment } %>
<%= turbo_stream.update dom_id(@task, :comment_form), partial: "comments/form", locals: { task: @task, comment: Comment.new } %>

show.html.erb の render @task の後へ、以下を追加します。

<div id="<%= dom_id(@task, :comments) %>"><%= render @task.comments.includes(:user) %></div>
<div id="<%= dom_id(@task, :comment_form) %>">
  <%= render "comments/form", task: @task, comment: @comment %>
</div>

名前空間付きコントローラなので、stream の partial は "comments/comment" と明記します。投稿者はフォームから受け取らず Current.user で決めます。第18章で、この UI にコメントの購読を追加します。

16.8 この段階のテストと後続章

16.6 のテストは第16章の単一フォームの一覧が対象です。[role=alert] を検証するため、この時点で scaffold のエラー表示コンテナへ role="alert" を加えます。第26章で別のフォームを同時に開けるようになったら、操作を within "#new_task_form" または within "dialog[open]" へ絞ります。完成版のテストは第28章を使います。

参考資料

第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 つの命令で実現できます。命令を並べるだけです。

この章は未検索・未ページ分割の一覧だけを対象にします。index と、保存・削除に成功した後の各アクションで @tasks = Task.order(id: :desc) を代入してください。POST / PATCH / DELETE には直前の GET の検索条件が自動では渡りません。第23・24章へ進むときは、第18章で説明する再取得方式へ置き換えます。

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 を直接書くと、絞り込みやページングに進んだときに、一覧と食い違います。一覧スコープはコントローラの @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

dom_id(task) が表示側の frame id と stream target をどちらも task_1 に揃えることを示す図。

ここまで、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 は dom_id で作ります。tasks や flash など、固定コンテナの 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 を使って、「他のユーザーの操作」をきっかけに、同じ更新を全員へ配信します。

17.7 空から作成したときも一覧を描き直す

create.turbo_stream.erb の prepend "tasks" も、17.3 と同じ replace "tasks", partial: "tasks/tasks", locals: { tasks: @tasks } に置き換えます。そうしないと「タスクはありません」と新しい行が同居します。更新で表示条件が変わる場合も同じ問題があります。

ここでの @tasks.size は全件を表示する段階の件数です。ページ分割後は filtered_scope.count とページ内のレコード数を区別します。一覧と件数・空状態を一緒に再取得すれば、最後の一件の削除も、空の一覧からの作成も同じ描画で扱えます。

参考資料

第18章 Action Cable でリアルタイム更新する

この章のねらい

第16章と第17章では、「自分の操作」をきっかけに画面を更新しました。フォームを送信した本人の画面が、ページ遷移なしで変わる仕組みです。

この章では、それを「他のユーザーの操作」へ広げます。あるユーザーがタスクを追加したら、同じプロジェクトを見ている全員の画面に、そのタスクがリアルタイムで現れる、という形です。

仕組みの土台は同じです。サーバーが Turbo Streams の命令を送り、ブラウザがそれを実行します。違うのは、命令を届ける経路です。これまではフォーム送信の応答でしたが、今回は Action Cable を通じて、購読している全員へ配信(broadcast)します。

18.1 条件のないプロジェクト一覧で仕組みを試す

リアルタイム更新を受け取るには、まず画面が「どの配信を聞くか」を宣言します。turbo_stream_from を使います。

app/views/projects/tasks_panel.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 を描いたものです。

この例は、対象プロジェクトの全件を id="tasks" に表示する読み取り専用の画面でだけ試します。検索、ページ分割、インライン編集と併用しません。@project は ProjectsController#tasks_panel で取得済みです。タスクの所属変更は元の配信先に remove を送らないため、この実験では禁止します。完成版では次の方式へ置き換えます。

Action Cable broadcast。1 人のタスク作成が broadcasts_to で project ストリームに配信され、turbo_stream_from で購読する全クライアントの画面に反映される配信図

18.3 コントローラからの broadcast

モデルの callback は「1 レコードの変化」を配信するのに向いています。しかし、第17章で見た件数バッジのように、レコード自体ではない部分も、他のユーザーへ更新したいことがあります。

そうした場合は、コントローラなどから明示的に 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、件数はコントローラからの broadcast、というように、配信したいものごとに送る形になります。

18.4 配信範囲の設計

ここで大切なのが、「誰に届くか」です。届く範囲は、配信先(streamable)と購読先が一致した相手です。turbo_stream_from @project を宣言した人だけが、@project 宛ての配信を受け取ります。

だから、配信先を何にするかが設計の要です。プロジェクト単位で見せたい更新なら、@project を配信先にします。これを、たとえば「全プロジェクト共通」のような広すぎる配信先にすると、関係のないユーザーにまで更新が飛びます。無駄な通信が増えるうえ、見せるべきでない情報が混ざる危険もあります。

「この更新は、誰に届くべきか」を先に決め、それに合った streamable を選ぶ。これが配信範囲の設計です。

18.5 認可の入口

配信範囲と認可は、別の話です。混同しないでください。

turbo_stream_from @project が生成する購読名は、署名されています。これは購読名の改ざんを防ぐもので、第三者が勝手に別の配信を聞き取ることを難しくします。しかし、これは認可ではありません。「そのユーザーが、そのプロジェクトを見てよいか」を判断しているわけではないからです。

アクセス制御は、別途コントローラやモデルの側で行います。たとえば「ログイン済みか」「そのプロジェクトのメンバーか」を確認し、見てよいユーザーにだけ 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 を学びます。

18.7 完成版では行を配らず変更を通知する

第23・24章と組み合わせる完成版では、18.2 の broadcasts_to を削除します。検索条件・ページ・プロジェクトが異なる閲覧者へ同じ行を配ると、表示条件に合わない行が入り、編集中のフォームも消えるためです。

代わりに after_commit でデータを含まない変更通知を送ります。所属プロジェクトが変わっても、全閲覧者が自身の条件で再取得するので、移動元・移動先の両方を再評価できます。

# Task のクラス内へ追加
 after_commit :announce_change

 private
 def announce_change
   Turbo::StreamsChannel.broadcast_update_to :tasks,
     target: "task_changes", partial: "tasks/change"
 end

一覧に turbo_stream_from :tasks と <div id="task_changes"></div> を置きます。通知を受ける partial と Stimulus は次のとおりです。Stimulus の仕組みは第19〜21章で学びます。ここでは完成版への接続点として読み、実装の置き換えは第23章で行ってください。

app/views/tasks/_change.html.erb

<span data-turbo-temporary data-controller="task-change"></span>

app/javascript/controllers/task_change_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  connect() { this.dispatch("received", { prefix: "tasks" }) }
}

app/javascript/controllers/task_list_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["frame", "notice", "location"]

  changed() {
    if (!this.element.isConnected) return
    if (document.querySelector("[data-editing], dialog[open]") || this.element.dataset.mode !== "pages") {
      this.noticeTarget.hidden = false
    } else {
      this.refresh()
    }
  }

  loaded(event) {
    if (event.target !== this.frameTarget || !this.pending) return
    this.pending = false
    this.changed()
  }

  refresh() {
    if (this.frameTarget.hasAttribute("busy")) {
      this.pending = true
      this.noticeTarget.hidden = false
      return
    }
    this.noticeTarget.hidden = true
    // フレームに表示中の条件を使う。遷移中の window.location には依存しない。
    const url = new URL(this.locationTarget.dataset.url, window.location.origin)
    if (this.element.dataset.mode !== "pages") url.searchParams.delete("page")
    if (this.frameTarget.src === url.href) this.frameTarget.reload()
    else this.frameTarget.src = url.href
  }
}

一覧フレームには data-task-list-target="frame"、親には data-controller="task-list" と data-action="tasks:received@document->task-list#changed turbo:frame-load->task-list#loaded" を付けます。location target に表示中の URL、notice target に更新通知を持たせます。これらを含むビュー全体は第24章の完成版に掲載します。再取得中に届いた変更通知は保留し、フレームの読み込み完了後にもう一度取得します。編集中・モーダル表示中は自動更新せず、通知と手動更新ボタンを出します。追記型一覧も先頭から読み直す必要があるので手動更新にします。要素を一括で morph すれば未保存入力を守れる、とは仮定しません。

コメントはタスクごとの固定一覧なので、show に turbo_stream_from @task, :comments を追加し、Comment の作成後に append、削除後に remove できます。送信者が HTTP 応答と配信の両方を受けても、partial のルートに一意の dom_id(comment) があるため append の同一 ID の扱いで重複を防げます。

app/models/comment.rb

class Comment < ApplicationRecord
  belongs_to :task
  belongs_to :user

  after_create_commit -> { broadcast_append_to task, :comments,
    target: ActionView::RecordIdentifier.dom_id(task, :comments) }
  after_destroy_commit -> { broadcast_remove_to task, :comments }

  validates :body, presence: true
end

学習用 Relay はログインした全員が全タスク・コメントを見られる単一チームです。変更通知にはタスク内容を含めません。権限のある製品では第31章の購読失効まで実装してください。

18.8 配信を動かす条件

開発用の async Action Cable アダプタは同じ Rails プロセス内だけに配信します。別プロセスの Rails console からは届きません。Web 画面から操作するか、複数プロセスで共有できる Solid Cable / Redis を設定します。

*_later_to は Active Job の実行環境も必要です。開発で async job を使うなら同一プロセスを起動したままにし、本番の Solid Queue では worker を起動します。この章の完成版の変更通知は同期の broadcast_update_to です。

二つのブラウザで試すときは、受信側の <turbo-cable-stream-source> に connected 属性が付いた後に操作します。配信は履歴の再送ではないため、購読前の変更は受信できません。テストの test アダプタは送信内容を記録し、同一プロセスへの配信も扱います。テストランナーと Web サーバーを別プロセスにする構成では、共有可能なアダプタを選びます。

参考資料

ハンズオン(第5部): 画面遷移なしで追加・更新・削除する

Turbo Streams で、Relay の作成・更新・削除を画面遷移なしに反映し、最後はリアルタイム更新まで広げます。

この部の到達状態

  • タスクの作成・更新・削除が prepend / replace / remove で反映される
  • 1 レスポンスで、行・件数・flash・空状態を同時に更新できる
  • コメントの追加・削除が部分更新で動く
  • Action Cable で、同じタスクを見ている全員にライブで反映される

作る・変える

  1. format.turbo_stream で create / update / destroy を stream 化する
  2. 複数 stream を 1 レスポンスに束ね、件数・空状態・flash を同時に更新する
  3. broadcasts_to / turbo_stream_from で broadcast を足す

完成条件

  • 2 つの画面を開き、片方の操作がもう片方に反映される
  • 最後の 1 件を消すと空状態が表示される
  • これらを System Test で確認できる

Relay の現在地

部分更新・複数同時更新・リアルタイム更新が揃った状態。 次の第6部で、サーバー不要の振る舞いを Stimulus で足します。

第6部 Stimulus

Stimulus を「HTML に振る舞いを足す小さな JavaScript」として学びます。状態は JavaScript の中ではなく HTML 側(data 属性)に持つので、Turbo がページや frame を差し替えても振る舞いは保たれます。コントローラ / action / target から Values / Classes / Outlets、そして外部ライブラリとの連携までを扱います。

第19章 Stimulus の基本

この章のねらい

第3部から第5部まで、画面の更新はすべてサーバーが担ってきました。リンク、フォーム、Turbo Streams、broadcast。どれも「サーバーが HTML を返し、ブラウザがそれを反映する」仕組みでした。

しかし、サーバーに問い合わせるまでもない振る舞いもあります。メニューの開閉、文字数のカウント、入力欄へのフォーカス。こうした「その場で完結する振る舞い」を担うのが Stimulus です。

この章では、Stimulus の思想と、もっとも基本的な書き方(コントローラと 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 コントローラ

Stimulus の振る舞いは、コントローラという単位で書きます。コントローラは、@hotwired/stimulus の Controller を継承した JavaScript のクラスです。

例として、要素にフォーカスを当てるだけの小さなコントローラを作ります。

app/javascript/controllers/autofocus_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.focus()
  }
}

connect() は、このコントローラが要素に結びついたときに呼ばれるメソッドです。this.element は、結びついた相手の要素を指します。つまりこのコントローラは、「結びついたら、その要素にフォーカスを当てる」という振る舞いです。

Rails には Stimulus 用のジェネレータもあります。

bin/rails generate stimulus autofocus

これで autofocus_controller.js の雛形が作られます。

19.3 data 属性

作ったコントローラを要素に結びつけるには、HTML 側で data-controller 属性にコントローラの名前を書きます。

app/views/tasks/_form.html.erb(抜粋)

<%= form.text_field :title, data: { controller: "autofocus" } %>

これで、このタイトル入力欄に autofocus コントローラが結びつき、表示されると同時にフォーカスが当たります。

data-controller の値が autofocus なら、autofocus_controller.js が対応します。ファイル名と data-controller の名前が対応するのが基本ルールです。1 つの要素に複数のコントローラを結びつけたいときは、空白で区切って並べます(data-controller="autofocus tooltip")。

19.4 Rails での配置

Stimulus のコントローラは、app/javascript/controllers/ に置きます。第6章で見たとおり、このディレクトリのコントローラは自動で登録されます(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() … コントローラが要素に結びついたときに呼ばれる
  • disconnect() … コントローラが要素から外れたときに呼ばれる

ここでいう「結びつく・外れる」は、最初のページ読み込みだけでなく、DOM の接続・切断に応じて起こります。Turbo Drive で visit して <body> が差し替わったとき、新しい要素に connect() が呼ばれます。frame が差し替わったときも、Turbo Streams で要素が追加・削除されたときも同じです。

19.1 で見た DOMContentLoaded は、最初の一度しか発火しませんでした。一方 Stimulus の connect() は、コントローラの要素が DOM へ接続されるたびに呼ばれます。update や morph でその要素が残る場合は再実行されません。だから、Turbo で動くアプリでは、DOMContentLoaded で書いた振る舞いは動かなくなり、Stimulus のコントローラは動き続けます。

Stimulus コントローラと data 属性。data-controller が HTML とコントローラを名前で結びつけ、Turbo の差し替えのたびに connect/disconnect が呼ばれることを、DOMContentLoaded(初回のみ)との対比で示す図

逆に、disconnect() で後始末をしないと、要素が消えても残骸が残ることがあります。第9章で見たキャッシュとも関わるこの後始末は、第22章で詳しく扱います。

第19章では、Stimulus を「HTML に振る舞いを足す仕組み」として理解し、コントローラ・data-controller・Turbo との関係を見ました。次の 第20章 では、Stimulus の中心概念であるコントローラ・action・target を、手を動かして覚えます。

参考資料

第20章 Controller / Action / Target

この章のねらい

第19章では、コントローラを要素に結びつけ、connect() で振る舞いを足しました。しかし、connect() だけでは「結びついたとき」しか動けません。

実際の UI では、「ボタンを押したら」「入力したら」といった操作に反応し、「この要素の値を読む」「あの要素を書き換える」といった要素の出し入れが必要です。これを担うのが action と target です。

この章では、Stimulus の 3 つの中心概念、コントローラ・action・target を、Relay のタスク説明欄に「文字数カウンタ」を作りながら覚えます。

20.1 コントローラの作成

作るのは、入力された文字数を数えて表示するカウンタです。コントローラを作ります。

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 の接続

カウンタは「入力されたとき」に動いてほしいので、入力イベントをコントローラのメソッドに結びつけます。これが 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 コントローラの count メソッドを呼ぶ」という意味です。

コントローラ側に count メソッドを足します。

app/javascript/controllers/counter_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  count(event) {
    console.log([...new Intl.Segmenter("ja", { granularity: "grapheme" }).segment(event.target.value)].length)
  }
}

これで、説明欄に入力するたびに、文字数が Console に出ます。event には、起きたイベントが渡されます。

なお、要素ごとに既定のイベントが決まっており、textarea の既定は input です。そのため、この場合は data-action="counter#count" とイベントを省いても同じく動きます。最初は省かずに書くと、何のイベントかが読み取りやすくなります。

20.3 target の参照

data-action がイベントをコントローラに、data-target が要素を this.xTarget に結びつける流れを示す図。

文字数を Console ではなく、画面に表示したいところです。表示先の要素を、コントローラから参照できるようにします。これが 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 にしています)。コントローラ側では、使う 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 = [...new Intl.Segmenter("ja", { granularity: "grapheme" }).segment(this.fieldTarget.value)].length
  }
}

this.fieldTarget が入力欄、this.outputTarget が表示先です。count は、入力欄の文字数を表示先に書き込みます。connect() でも count() を呼んでおけば、最初の表示時にも正しい文字数が出ます(編集画面で既存の説明があるときに効きます)。

これで、入力するたびに画面の文字数が更新されます。サーバーへの問い合わせは一切ありません。

20.4 複数 target

target は、同じ名前を複数の要素に付けられます。その場合は、複数形の this.名前Targets で、配列として受け取れます。

たとえば、複数のチェックボックスをまとめて操作するコントローラを考えます。

<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 よくある命名ミス

コントローラ・action・target は、名前で結びつきます。だから、名前のずれが、そのままつまずきになります。代表的なものを挙げます。

  • コントローラ名とファイル名のずれ。data-controller="counter" なら counter_controller.js です。ここがずれると、コントローラがまったく結びつきません。
  • action の書式ミス。イベント->controller名#メソッド名 の -> や # を間違える、コントローラ名をタイプミスする、と動きません。
  • target の宣言漏れ。data-counter-target="output" を付けても、コントローラ側の static targets に "output" を書き忘れると、アクセサ自体が生成されず this.outputTarget は undefined になります。宣言済みなのに対応要素がないときは、単数の target アクセスで Missing 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 "説明", with: "hello"

    assert_selector "[data-counter-target='output']", text: "5"
  end
end

fill_in で説明欄に入力すると、input イベントが発火し、count が呼ばれ、output target に文字数が出ます。assert_selector で、その表示が 5 になっていることを確認します。

第20章では、コントローラ・action・target で、操作に反応して要素を出し入れする振る舞いを作りました。次の第21章では、コントローラに設定値を渡す Values、状態を表す CSS Classes、他のコントローラとつなぐ Outlets を学び、「状態を HTML に置く」という第6部の軸を深めます。

20.7 何を一文字と数えるか

JavaScript の文字列の length は UTF-16 のコード単位数です。絵文字や結合文字を利用者が見る一文字として数えるため、この例は Intl.Segmenter の grapheme を使います。サーバー側の文字数制限と自動的に同じ単位になるわけではありません。サンプルは説明欄の参考表示として使い、文字数制限は設けていません。

参考資料

第21章 Values / Classes / Outlets

この章のねらい

第20章で、コントローラ・action・target を覚えました。これで操作に反応できますが、まだ足りないものがあります。

たとえば「3 秒後に消す」というコントローラを作るとき、その「3 秒」をどこに持たせるでしょうか。JavaScript に直接書くと、画面ごとに秒数を変えられません。また、付け外しする CSS クラス名を JavaScript に直書きすると、見た目の都合が JavaScript に漏れます。

この章では、こうした設定値・クラス名・コントローラ同士の接続を、HTML 側に持たせる仕組みを学びます。Values・CSS Classes・Outlets です。これは第6部の軸「状態を HTML に置く」の核心です。

21.1 Values

Values は、コントローラに渡す設定値です。HTML の data 属性で渡し、コントローラでは型付きで受け取れます。

例として、一定時間で自動的に消えるトースト(第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 は、指定がないときの既定値です。コントローラの中では 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

コントローラの中で CSS クラスを付け外しすることはよくあります。このとき、クラス名を JavaScript に直書きすると、見た目の都合が JavaScript に漏れます。CSS Classes を使うと、クラス名も HTML 側に置けます。

開閉するコントローラを例にします。

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 type="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 は、コントローラから別のコントローラを参照する仕組みです。離れた場所にあるコントローラ同士をつなぎたいときに使います。

たとえば、検索フォームのコントローラから、一覧のコントローラを操作したい、といった場面です(ここでは Outlets 単独の例として扱います。第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="#project_tasks">
  ...
</div>

<%= turbo_frame_tag "project_tasks", src: tasks_panel_project_path(@project), data: { controller: "list" } %>

ここで大切なのは、data-search-list-outlet のセレクタ(#project_tasks)が指す要素が、それ自身 list コントローラである(data-controller="list" を持つ)ことです。Outlet は「セレクタで要素を探し、その要素に結びついたコントローラを参照する」仕組みだからです。

this.listOutlet で、#project_tasks に結びついた list コントローラのインスタンスを参照し、そのメソッド(reload など)を呼べます。Outlets は強力ですが、コントローラ同士を密に結びつけるので、使いすぎると関係が追いにくくなります。「本当にコントローラ間の連携が要るか」を見極めて使います(Outlets は Stimulus 3.2 以降の機能です)。

21.4 状態を持つべきかの判断

Values・Classes・Outlets を学ぶと、コントローラに何でも持たせたくなります。しかし、Stimulus の思想は「状態は HTML に置く」でした。

判断の目安は、こうです。

  • 設定値(秒数、URL、上限など)は、Values で HTML に置く
  • 付け外しするクラス名は、CSS Classes で HTML に置く
  • いまの状態(開いているか、選択中かなど)も、できるだけ DOM に表す(クラスや属性で)
  • コントローラのインスタンス変数に状態をため込むのは、最小限にする

コントローラの中(JavaScript のインスタンス変数)に状態をため込むと、Turbo がページや frame を差し替えたときに、その状態は失われます。次の 21.5 で、その理由を見ます。

21.5 HTML 側に情報を置く利点

状態を data 属性に置けばスナップショットに乗るが、JS のインスタンス変数だけだと差し替えで失われることを対比する図。

なぜ、状態を HTML に置くのでしょうか。最大の理由は、Turbo と噛み合うからです。

第9章で見たとおり、Turbo はページのスナップショットをキャッシュし、復元します。このスナップショットは「そのときの HTML」です。設定値や状態が HTML の data 属性やクラスに表れていれば、それらはスナップショットに含まれ、復元時にもそのまま戻ります。コントローラは、復元された HTML を見て、connect() で自分を組み立て直せます。

逆に、状態をコントローラのインスタンス変数だけに持っていると、差し替えや復元のたびに消えます。「さっき開いていたメニューが、戻ったら閉じている」といった不整合が起きます。

HTML 側に情報を置くことは、宣言的で読みやすいだけでなく、Turbo の差し替え・キャッシュに強い、という実利があります。これが、第6部を通じての「状態を HTML に置く」という指針の理由です。

第21章では、Values・Classes・Outlets で、設定値・クラス名・コントローラ連携を HTML 側に持たせました。次の第22章では、Stimulus から外部の JavaScript ライブラリを安全に扱う方法を学び、第6部を締めます。

21.6 CSS と Outlet の参照先を用意する

d-none はブラウザ標準のクラスではありません。app/assets/stylesheets/application.css に定義します。

.d-none { display: none !important; }
.skeleton { padding: 1rem; background: #eee; }

21.3 の list.reload() も Stimulus の組み込みメソッドではありません。次のコントローラを作り、list を付ける要素を src 付きのフレームにします。この例の識別子 search は第23章のものとは別案なので、完成版へ追加しません。

// app/javascript/controllers/list_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  reload() { this.element.reload() }
}
<%= turbo_frame_tag "project_tasks", src: tasks_panel_project_path(@project), data: { controller: "list" } %>

この場合、Outlet セレクタも #project_tasks にします。応答の frame ID は同じ outlet_tasks に揃える必要があります。第13章の project_tasks を流用するなら、ID とセレクタを両方 project_tasks に揃えてください。

data 属性が保持されるのはスナップショットを復元したときです。新しいサーバー HTML で全置換すれば、状態はその HTML の値に戻ります。「HTML に置けば、すべての更新で保持される」という意味ではありません。

参考資料

第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() は、対象コントローラの要素が DOM に接続されるたびに呼ばれます。ルートが残る update / morph では再実行されません。

たとえば、一覧と詳細を行き来すると、そのたびに詳細の connect() が走ります。connect() でライブラリを初期化していれば、行き来のたびに初期化されます。disconnect() で破棄していないと、前の初期化が残ったまま、新しい初期化が重なります。これが二重初期化です。

Turbo は素の DOM を差し替えます。ライブラリが作った内部状態や、要素の外に追加した DOM(ポップアップなど)は、Turbo は知りません。だから、disconnect() で自分で片付ける必要があります。

22.3 チャートと日付ピッカーを実装する

先に importmap へ、import する名前そのものを pin します。chart.js だけを pin しても chart.js/auto は自動では解決されません。

bin/importmap pin chart.js/auto@4.4.9 flatpickr@4.6.13

生成後は bin/importmap json で chart.js/auto と flatpickr、それらの依存が解決されることを確認します。完成版は依存を含めた ESM をローカルに固定配置し、次の名前で読み込みます。

pin "chart.js/auto", to: "chart.js--auto.js"
pin "flatpickr", to: "flatpickr.js"

app/javascript/controllers/chart_controller.js

import { Controller } from "@hotwired/stimulus"
import Chart from "chart.js/auto"

export default class extends Controller {
  static values = { counts: Array }
  connect() {
    if (document.documentElement.hasAttribute("data-turbo-preview")) return
    this.chart = new Chart(this.element, {
      type: "bar",
      data: { labels: ["todo", "in_progress", "done"], datasets: [{ label: "タスク数", data: this.countsValue }] },
      options: { animation: false, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } }
    })
  }
  teardown() { this.chart?.destroy(); this.chart = null }
  disconnect() { this.teardown() }
}

app/javascript/controllers/datepicker_controller.js

import { Controller } from "@hotwired/stimulus"
import flatpickr from "flatpickr"

export default class extends Controller {
  connect() {
    if (document.documentElement.hasAttribute("data-turbo-preview")) return
    this.picker = flatpickr(this.element, { dateFormat: "Y-m-d", allowInput: true })
  }
  teardown() { this.picker?.destroy(); this.picker = null }
  disconnect() { this.teardown() }
}

チャートは canvas、日付ピッカーは入力要素へ結びつけます。日付ピッカーの CSS も app/assets/stylesheets/flatpickr.css に配置します(サンプルに同梱)。

<%= tag.canvas data: { controller: "chart", chart_counts_value: [3, 2, 1], action: "turbo:before-cache@document->chart#teardown" }, role: "img", aria: { label: "todo 3件、in_progress 2件、done 1件" } %>
<%= form.date_field :due_on, data: { controller: "datepicker", action: "turbo:before-cache@document->datepicker#teardown" } %>

実際のグラフはプロジェクト概要に置き、数値もテキストで併記します。日付はキーボードでも入力できます。ブラウザ標準の日付入力だけで十分なら、外部ライブラリを導入しない選択もあります。

22.4 cleanup

disconnect() で破棄するもの、というのは、具体的には次のようなものです。

  • ライブラリのインスタンス(destroy() などで)
  • 自分で登録したタイマー(clearTimeout / clearInterval)
  • 自分で window や document に登録したイベントリスナー(removeEventListener)
  • 要素の外に追加した DOM

第21章のトーストでも、disconnect() で clearTimeout していました。connect() で何かを始めたら、それを disconnect() で止める、と対にして考えます。

22.5 Turbo cache との相互作用

connect で初期化し disconnect で破棄し、before-cache で片付ける流れを示すタイムライン。

もう 1 つ、キャッシュ特有の注意があります。第9章で見たとおり、Turbo はページを離れる直前にスナップショットを保存し、戻る・進むでは利用可能なキャッシュを復元します。application visit の再訪時にはプレビューにも使います。

ここで問題になるのは、スナップショットが保存されるのは、要素が消える前(つまり 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() { /* 初期化結果を this.widget に代入する */ }
  teardown() {
    this.widget?.destroy()
    this.widget = null
  }
}

turbo:before-cache で teardown() を呼び、スナップショットには初期化前のきれいな DOM が入るようにします。これで、プレビュー表示で壊れた見た目が出るのを防げます。第9章で「外部ライブラリは第22章で扱う」と送っていたのが、この後始末です。

22.6 importmap での外部ライブラリ読み込み

最後に、外部ライブラリの読み込みです。第6章で見たとおり、本書は importmap を使います。外部ライブラリは、公開された ES モジュールを pin して読み込みます。

bin/importmap pin chart.js/auto@4.4.9

これで 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」として学び、コントローラ・action・target、Values・Classes・Outlets、そして外部ライブラリとの安全な連携まで見ました。次の第7部では、ここまでの Turbo と Stimulus を組み合わせて、検索・ページネーション・モーダル・通知といった実務的な UI を作ります。

22.7 二度の破棄と検証

before-cache の後に disconnect が来るため、teardown は何度呼んでも安全にします。インスタンスを破棄したら null に戻し、次の呼び出しでは何もしません。プレビュー中は初期化を省き、本表示時に初期化します。

ブラウザでプロジェクト概要のチャートを開き、別ページへ進む・戻るを繰り返してください。期限欄も開閉し、余分なカレンダー DOM が残らないこと、フォームが送れることを確認します。

参考資料

ハンズオン(第6部): カウンタ、通知、外部ライブラリを組み込む

サーバーとのやり取りが要らない振る舞いを、Stimulus で Relay に足します。

この部の到達状態

  • ドロップダウンメニューが Stimulus だけで開閉する
  • タスクの説明入力欄に文字数カウンタが付く
  • 削除前に確認ダイアログが出る
  • 自動で消えるトーストのコントローラが動く(表示秒数は Values で HTML から渡す)
  • due_on の date picker(外部ライブラリ)が、Turbo 遷移・cache をまたいでも安全に動く

作る・変える

  1. コントローラ / action / target の基本を、文字数カウンタで覚える
  2. Values / Classes / Outlets で、トーストの表示秒数・表示切り替え・UI 間連携を実装する
  3. 外部ライブラリを 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 絞り込みを一か所にまとめる

TasksController の private 節へ filtered_tasks を置きます。SQLite で % と _ を普通の文字として検索するには、sanitize_sql_like と明示的な ESCAPE の両方が必要です。SQL の値はバインドします。

def filtered_tasks
  scope = Task.includes(:assignee, :tags).order(id: :desc)
  if params[:q].present?
    scope = scope.where("title LIKE ? ESCAPE ?", "%#{Task.sanitize_sql_like(params[:q])}%", "\\")
  end
  scope = scope.where(status: params[:status]) if params[:status].present?
  scope = scope.where(project_id: params[:project_id]) if params[:project_id].present?
  scope = scope.joins(:taggings).where(taggings: { tag_id: params[:tag_id] }).distinct if params[:tag_id].present?
  scope
end

この章の index は @task = Task.new、@tasks = filtered_tasks、@total_count = @tasks.count を用意します。第24章でページ分割を加えます。

タグ編集には _form に form.collection_check_boxes :tag_ids, Tag.order(:name), :id, :name を追加し、task_params に tag_ids: [] を許可します。fieldset と legend でタグ群に名前を付けます。タグ自体の作成 UI は対象外で、seed のタグから選びます。

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.label :q, "タイトルで検索" %>
  <%= form.search_field :q, value: params[:q] %>
  <%= form.label :status, "ステータス" %>
  <%= 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 で debounce した GET 検索が frame を差し替え、replace で URL を反映する構成図。

「検索」ボタンを押さなくても、入力に追従して絞り込みたいところです。Stimulus で、入力のたびにフォームを送信します。ただし、1 文字ごとに送るとリクエストが多すぎるので、少し待ってから送る(debounce)ようにします。

app/javascript/controllers/search_controller.js

app/javascript/controllers/search_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  static values = { delay: { type: Number, default: 300 } }
  connect() { this.lastSubmitted = this.signature() }
  signature() { return new URLSearchParams(new FormData(this.element)).toString() }
  startComposition() { this.composing = true; clearTimeout(this.timeout) }
  endComposition() { this.composing = false; this.submit() }
  submit(event) {
    clearTimeout(this.timeout)
    if (this.composing || event?.isComposing) return
    this.timeout = setTimeout(() => {
      const signature = this.signature()
      // input の後の blur に伴う change で、同じ検索を再送しない。
      if (signature === this.lastSubmitted) return
      this.lastSubmitted = signature
      this.element.requestSubmit()
    }, this.delayValue)
  }
  disconnect() { clearTimeout(this.timeout) }
}

フォームにコントローラと action を付けます。

<%= form_with url: tasks_path, method: :get,
      data: { turbo_frame: "task_list", controller: "search",
              action: "input->search#submit change->search#submit compositionstart->search#startComposition compositionend->search#endComposition" } 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="replace" で現在の履歴を更新する

ここまでで一覧は絞り込めますが、1 つ問題があります。frame の差し替えでは URL が変わりません(第11章)。検索結果を共有・リロードできるようにするには、URL に条件を反映させる必要があります。

frame に data-turbo-action="replace" を付けます。

<%= turbo_frame_tag "task_list", data: { turbo_action: "replace" } do %>
  <%= render "tasks", tasks: @tasks %>
<% end %>

replace を付けると、frame の差し替えに合わせてブラウザの URL も更新されます。/tasks?q=bug&status=todo のような URL になり、共有・リロード・再読み込みで検索結果を取得できます。入力のたびに戻る履歴を増やさないため replace を選びます。検索条件が URL に残るので、誰かに送れば同じ結果が再現できます。

23.7 フレーム外を更新したくなったときの判断

「検索結果の件数も出したい」となったとき、件数バッジを frame の外に置くと、frame の差し替えでは更新できません(第14章)。一覧という 1 か所だけを差し替えているからです。

選択肢は 2 つです。

  • 件数バッジを task_list frame の中に置く。こうすれば、結果と一緒に更新されます。検索の範囲では、これがいちばん素直です。
  • どうしても frame の外(離れた場所)に件数を置きたいなら、Turbo Streams へ切り替えます(第5部)。複数箇所の同時更新は Streams の領分です。

まずは frame の中に収める。収まらなくなったら Streams を検討する。この判断軸(第14章)が、ここでも効きます。

23.8 a11y

検索は、目で見ているユーザーには自然ですが、配慮が要ります。

  • 結果全体ではなく件数の短い文に role="status" を付けます。一覧の全文を毎回読み上げないためです。件数(「3 件見つかりました」など)を結果の中に出すと、より親切です。
  • 入力に追従して送信するので、フォーカスは検索ボックスに留めます。requestSubmit() はフォーカスを動かさないので、入力を続けられます。
<%= turbo_frame_tag "task_list", data: { turbo_action: "replace" } do %>
  <p role="status"><%= @total_count %> 件該当</p>
  <%= render "tasks", tasks: @tasks %>
<% end %>

23.9 テスト

検索は、System Test と Request Test で役割を分けて確かめます。

  • System Test … 入力すると一覧が絞り込まれる、という「画面の振る舞い」を確かめます。Stimulus の debounce や frame の差し替えを含みます。
  • Request Test … パラメータに対して、コントローラが正しい結果を返すという「サーバーの絞り込み」を確かめます。get tasks_path(q: "bug") で、期待する件数・内容になるかを見ます。

System Test だけだと、絞り込み条件の網羅が重くなります。条件の組み合わせは Request Test で、画面の追従は System Test で、と分けると、軽く確実にテストできます。

23.10 アンチパターン

  • debounce なし。1 文字ごとに送ると、リクエストが大量に飛びます。必ず待ちを入れます。
  • URL に条件を残さない。frame の差し替えだけで replace を付けないと、共有もリロードもできません。
  • 単一箇所なのに Streams で全置換。一覧 1 か所の更新なら frame で十分です。なんでも Streams にすると、かえって複雑になります。

第23章では、検索を Turbo Frames+Stimulus で作り、URL に条件を残しました。次の 第24章 では、件数の多い一覧を扱うページネーションと無限スクロールを学びます。

23.11 CRUD・broadcast と検索を統合する

第17章の @tasks を使う直接置換はここで終了します。POST に GET の検索条件が自動で引き継がれることはありません。第18章の変更通知方式を有効にし、一覧を自分の URL で取得し直します。作成・削除で空状態と件数も更新できます。

完成版のコントローラと一覧は第24章に掲載します。検索・タグ・ページ分割・更新通知を併用する場合は、その組で置き換えてください。新規フォームはフレームの外に置き、一覧を取得し直しても入力内容が消えないようにします。

参考資料

第24章 ページネーションと無限スクロール

この章のねらい

タスクが増えると、一覧を一度に全部出すわけにはいきません。この章では、件数の多い一覧を分割して見せる方法を、3 段階で学びます。

通常のページネーション、「もっと読む」ボタンによる追加読み込み、そして無限スクロールです。それぞれに向き不向きがあり、どの Hotwire の道具を使うかも変わります。

24.1 完成イメージ

  • ページ送り … ?page=2 のようにページを切り替える、いちばん基本的な形
  • もっと読む … ボタンを押すと、続きが一覧の末尾に追加される形
  • 無限スクロール … 一覧の末尾までスクロールすると、自動で続きが読み込まれる形

下に行くほど「途切れず読める」一方で、操作性やアクセシビリティの注意が増えます。

24.2 この章の選択

3 段階で、使う道具が変わります。

  • ページ送りは、一覧を別ページの内容で置換します。Turbo Frames が向きます。
  • もっと読むは、続きを末尾に追記します。Turbo Streams の append が向きます。
  • 無限スクロールは、「末尾が見えた」ことを検知して、もっと読むを自動で押します。検知は Stimulus が担います。

置換は Frames、追記は Streams、検知は Stimulus。第7部の判断軸が、ここでも効きます。

ページネーション3段階の比較。ページ送り(Frames で置換)・もっと読む(Streams で追記)・無限スクロール(Stimulus で検知+追記)を、URL 再現性と a11y の観点で並べた比較図

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
  @task = Task.new
  scope = filtered_tasks
  @total_count = scope.count
  @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(request.query_parameters.merge(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(request.query_parameters.merge(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(request.query_parameters.merge(page: @next_page)), data: { turbo_stream: true, infinite_scroll_target: "button" } %>
  <% end %>
</div>

コントローラは、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(request.query_parameters.merge(page: @next_page)), data: { turbo_stream: true, infinite_scroll_target: "button" } %>
  <% end %>
<% end %>

「もっと読む」を押すと、次ページのタスクが id="tasks" の末尾に append され、ボタン自体は次ページ用のボタンに update されます。最終ページでは @next_page が nil なので、ボタンが消えます。一覧は置き換わらず、下に伸びていきます。

24.6 IntersectionObserver で自動化する

無限スクロールは、この「もっと読む」を自動で押す形です。末尾が画面に見えたことを検知して、ボタンをクリックします。検知には、ブラウザの IntersectionObserver を使います。これを Stimulus コントローラで包みます。

app/javascript/controllers/infinite_scroll_controller.js

app/javascript/controllers/infinite_scroll_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  static targets = ["button"]
  initialize() {
    this.observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting && entry.target.isConnected) {
          // 同じリンクの自動送信は一度だけ。失敗時は手動で再試行できる。
          this.observer.unobserve(entry.target)
          entry.target.click()
        }
      }
    })
  }
  buttonTargetConnected(button) { this.observer.observe(button) }
  buttonTargetDisconnected(button) { this.observer.unobserve(button) }
  disconnect() { this.observer.disconnect() }
}

buttonTargetConnected は初回の connect より先に呼ばれます。そのため observer は initialize で作ります。新旧のリンクを同じ partial から描き、更新後にも target 属性を残します。

ここがこの章の肝です。「もっと読む」を押すと、24.5 のとおり update "pagination" でボタンが新しい要素に差し替わります。もし connect() で最初のボタンだけを監視していると、差し替え後は削除済みの旧ボタンを見続け、2 回目以降が発火しません。そこで、ターゲットが差し替わるたびに呼ばれる buttonTargetConnected / buttonTargetDisconnected を使い、新しいボタンを監視し直します。最終ページでボタンが消えれば、buttonTargetDisconnected で監視が外れ、自動読み込みも止まります。

<div id="pagination" data-controller="infinite-scroll">
  <% if @next_page %>
    <%= link_to "もっと読む", tasks_path(request.query_parameters.merge(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 コントローラのターゲットになります。

「もっと読む」ボタンが画面に見えると、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 の契約を、ここで本格的に使います。

24.11 完成版への置き換え

ここまでのページ送り・追記型・自動読み込みは、同じ場所へ重ねて追加するコードではなく、表示方式の選択肢です。完成版では mode=pages / more / infinite で切り替えます。以下は第23章の検索、CRUD、モーダルの分岐も含む統合版です。第25・26章ではフォームとモーダル側を読み解きます。

app/controllers/tasks_controller.rb

class TasksController < ApplicationController
  before_action :set_task, only: %i[show edit update destroy]
  PER_PAGE = 20

  def index
    @task = Task.new
    scope = filtered_tasks
    @total_count = scope.count
    @page = [ params[:page].to_i, 1 ].max
    @paged_tasks = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
    @next_page = @page + 1 if @total_count > @page * PER_PAGE
    @mode = %w[more infinite].include?(params[:mode]) ? params[:mode] : "pages"
    respond_to do |format|
      format.html
      format.turbo_stream do
        if @mode != "pages" && params[:page].present?
          render :index
        else
          render :index, formats: [ :html ], content_type: "text/html"
        end
      end
    end
  end

  def show
    @comment = Comment.new
  end

  def new
    @task = Task.new
  end

  def edit
  end

  def create
    @task = Task.new(task_params)
    if @task.save
      respond_to do |format|
        format.turbo_stream { flash.now[:notice] = "タスクを作成しました。" }
        format.html { redirect_to @task, notice: "タスクを作成しました。", status: :see_other }
      end
    elsif turbo_frame_request_id == "modal"
      render :new, formats: [ :html ], status: :unprocessable_entity
    elsif params[:form_context] == "inline" && request.format.turbo_stream?
      render turbo_stream: turbo_stream.update("new_task_form", partial: "tasks/form",
        locals: { task: @task, context: "inline" }), status: :unprocessable_entity
    else
      render :new, formats: [ :html ], status: :unprocessable_entity
    end
  end

  def update
    if @task.update(task_params)
      respond_to do |format|
        format.turbo_stream { flash.now[:notice] = "タスクを更新しました。" }
        format.html { redirect_to @task, notice: "タスクを更新しました。", status: :see_other }
      end
    else
      render :edit, formats: [ :html ], status: :unprocessable_entity
    end
  end

  def destroy
    @task.destroy!
    respond_to do |format|
      format.turbo_stream { flash.now[:notice] = "タスクを削除しました。" }
      format.html { redirect_to tasks_path, notice: "タスクを削除しました。", status: :see_other }
    end
  end

  private

  def set_task
    @task = Task.find(params[:id])
  end

  def task_params
    params.expect(task: [ :project_id, :title, :description, :status, :due_on, :assignee_id, tag_ids: [] ])
  end

  def filtered_tasks
    scope = Task.includes(:assignee, :tags).order(id: :desc)
    if params[:q].present?
      scope = scope.where("title LIKE ? ESCAPE ?", "%#{Task.sanitize_sql_like(params[:q])}%", "\\")
    end
    scope = scope.where(status: params[:status]) if params[:status].present?
    scope = scope.where(project_id: params[:project_id]) if params[:project_id].present?
    scope = scope.joins(:taggings).where(taggings: { tag_id: params[:tag_id] }).distinct if params[:tag_id].present?
    scope
  end
end

app/views/tasks/index.html.erb

<% content_for :title, "タスク" %>
<h1>タスク</h1>
<%= turbo_stream_from :tasks %>
<div id="task_changes"></div>
<p><%= link_to "新規作成(モーダル)", new_task_path, id: "new_task_modal_link", data: { turbo_frame: "modal" } %></p>
<section class="new-task">
  <h2>新規タスク</h2>
  <div id="new_task_form"><%= render "form", task: @task, context: "inline" %></div>
</section>
<%= form_with url: tasks_path, method: :get,
  data: { turbo_frame: "task_list", turbo_action: "replace", controller: "search",
    action: "input->search#submit change->search#submit compositionstart->search#startComposition compositionend->search#endComposition" } do |form| %>
  <%= form.label :q, "タイトルで検索" %>
  <%= form.search_field :q, value: params[:q] %>
  <%= form.label :status, "ステータス" %>
  <%= form.select :status, Task.statuses.keys, { include_blank: "すべて", selected: params[:status] } %>
  <%= form.label :project_id, "プロジェクト" %>
  <%= form.collection_select :project_id, Project.order(:name), :id, :name, { include_blank: "すべて", selected: params[:project_id] } %>
  <%= form.label :tag_id, "タグ" %>
  <%= form.collection_select :tag_id, Tag.order(:name), :id, :name, { include_blank: "すべて", selected: params[:tag_id] } %>
  <%= form.hidden_field :mode, value: @mode %>
  <%= form.submit "検索" %>
<% end %>
<nav aria-label="一覧の表示方式">
  <% { "pages" => "ページ送り", "more" => "もっと読む", "infinite" => "自動読み込み" }.each do |mode, label| %>
    <%= link_to label, tasks_path(request.query_parameters.except("page").merge(mode: mode)) %>
  <% end %>
</nav>
<section data-controller="task-list" data-mode="<%= @mode %>" data-action="tasks:received@document->task-list#changed turbo:frame-load->task-list#loaded">
  <p hidden data-task-list-target="notice" role="status">更新があります。編集中の内容を保存してから
    <button type="button" data-action="task-list#refresh">最新の一覧を表示</button>してください。追記型は先頭から表示します。
  </p>
  <%= turbo_frame_tag "task_list", data: { turbo_action: "advance", task_list_target: "frame" } do %>
    <p role="status" data-task-list-target="location" data-url="<%= request.fullpath %>"><span id="task_count"><%= @total_count %></span> 件該当(現在 <%= @page %> ページ)</p>
    <%= render "tasks", tasks: @paged_tasks %>
    <% if @mode == "pages" %>
      <nav aria-label="ページ送り">
        <%= link_to "前へ", tasks_path(request.query_parameters.merge(page: @page - 1)) if @page > 1 %>
        <%= link_to "次へ", tasks_path(request.query_parameters.merge(page: @next_page)) if @next_page %>
      </nav>
    <% else %>
      <div id="pagination" data-controller="<%= "infinite-scroll" if @mode == "infinite" %>">
        <%= render "pagination" %>
      </div>
    <% end %>
  <% end %>
</section>

app/views/tasks/_pagination.html.erb

<% if @next_page %>
  <%= link_to "もっと読む", tasks_path(request.query_parameters.merge(page: @next_page)),
    data: { turbo_stream: true, infinite_scroll_target: "button" } %>
<% end %>

app/views/tasks/index.turbo_stream.erb

<%= turbo_stream.append "tasks", partial: "tasks/task", collection: @paged_tasks %>
<%= turbo_stream.update "pagination", partial: "tasks/pagination" %>

app/views/tasks/create.turbo_stream.erb

<%= turbo_stream.update "new_task_form" do %>
  <%= render "form", task: Task.new, context: "inline" %>
<% end %>
<%= turbo_stream.update "modal" %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

app/views/tasks/update.turbo_stream.erb

<%= turbo_stream.replace @task %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

app/views/tasks/destroy.turbo_stream.erb

<%= turbo_stream.remove @task %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

@total_count は全該当件数、@paged_tasks は現在のページの行です。変更通知後は一覧フレームを再取得し、件数・空状態・次ページの有無を同じ条件で再計算します。編集中と追記モードでは自動で全置換せず通知を出します。

offset 型は読み込みの途中でレコードが増減するとページ境界が動きます。静止したデータでの重複防止テストと、同時更新時の順序保証は別です。本番で厳密な連続性が必要ならカーソル型を検討します。

参考資料

第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 を画面に反映します。Drive の非 GET 送信にリダイレクトなしの 200 HTML を返すと再描画されません。Frame の 200 HTML や Turbo Stream の 200 応答は、この制約とは別です(第8章)。失敗は必ず 422、と徹底します。

25.4 フォームを同じ場所に戻す

失敗時は、入力中のフォームを、エラー付きで同じ場所に戻します。やり方は、これまでに学んだ 2 つのどちらかです。

  • インライン編集のように frame の中で完結しているなら、frame に 422 のフォームを返します(第12章)。
  • 一覧ページの inline フォームなら、Turbo Streams でフォーム領域を差し替えます(第16章)。

どちらも、ページ遷移せずに、エラーの付いたフォームがその場に現れ、ユーザーは入力をやり直せます。

25.5 成功時の更新

第24章の完成版の stream テンプレートを使います。フォームを空にし、フラッシュを更新し、一覧へ変更通知を渡します。検索・ページ分割のある画面に無条件で prepend すると、条件に合わないタスクまで入るので、完成版では行を直接追加しません。

25.6 Stimulus で補助する

ここから、体験をよくする補助です。

送信中の表示と二重送信の防止は、実は Turbo に組み込みの助けがあります。Turbo はフォーム送信中、送信ボタンを自動で無効にします。さらに、ボタンに data-turbo-submits-with を付けると、送信中だけ文言を差し替えられます。

<%= form.submit "作成", data: { turbo_submits_with: "作成中…" } %>

これだけで、送信中はボタンが「作成中…」になり、押せなくなります。二重送信の多くは、これで防げます。

文字数カウンタのような入力中の補助は、第20章で作った Stimulus のコントローラを使います。これらは入力体験を助けるもので、サーバー検証とは別物です。

25.7 a11y

422 で再描画されたフォームに、エラーサマリ、aria-invalid、aria-describedby、送信中ボタン無効化が付く様子を示す図。

フォームのアクセシビリティは、この章の主戦場です。エラーを「見える」だけでなく「伝わる」ようにします。

  • エラーサマリ。フォームの先頭に、エラーの一覧を出します。role="alert" を付けると、表示時に読み上げられます。さらに、再描画後にここへフォーカスを移すと、ユーザーはすぐエラーに気づけます。フォーカス移動は、第19章で作った autofocus コントローラ(connect で this.element.focus() する)を再利用します。connect は frame の差し替えでも Turbo Streams の差し込みでも呼ばれるので、新たなエラーサマリ要素が接続されたときにフォーカスが移ります。ルート要素を残す morph では connect が再実行されるとは限りません(HTML の autofocus 属性は frame/ページの描画後には効きますが、stream で差し込んだ要素には効かないため、connect 方式に寄せます)。
  • 各フィールドの紐づけ。エラーのあるフィールドに aria-invalid="true" を付け、aria-describedby でそのフィールドのエラーメッセージと結びつけます。aria-invalid は、エラーがないときは "false"(妥当である、という意味)になります。これは仕様どおりで問題ありません。

app/views/tasks/_form.html.erb

<% context = local_assigns.fetch(:context, "page") %>
<% scope_id = "#{context}_#{dom_id(task)}" %>
<%= form_with model: task, namespace: scope_id,
  data: { turbo: context != "page" } do |form| %>
  <%= hidden_field_tag :form_context, context %>
  <% if task.errors.any? %>
    <div role="alert" tabindex="-1" data-controller="autofocus" class="error-summary">
      <%= pluralize(task.errors.count, "件") %>のエラーがあります。
      <ul>
        <% task.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :project_id, "プロジェクト" %>
    <%= form.collection_select :project_id, Project.order(:name), :id, :name, prompt: "選択してください" %>
  </div>

  <div>
    <%= form.label :title, "タイトル" %>
    <%= form.text_field :title,
          "aria-invalid": task.errors[:title].any?,
          "aria-describedby": ("#{scope_id}_title_error" if task.errors[:title].any?) %>
    <% if task.errors[:title].any? %>
      <span id="<%= scope_id %>_title_error" class="field-error"><%= task.errors[:title].to_sentence %></span>
    <% end %>
  </div>

  <div data-controller="counter">
    <%= form.label :description, "説明" %>
    <%= form.text_area :description, data: { counter_target: "field", action: "input->counter#count" } %>
    <span data-counter-target="output">0</span> 文字
  </div>

  <div>
    <%= form.label :status, "ステータス" %>
    <%= form.select :status, Task.statuses.keys, selected: task.status %>
  </div>

  <div>
    <%= form.label :assignee_id, "担当者" %>
    <%= form.collection_select :assignee_id, User.order(:name), :id, :name, prompt: "未割り当て" %>
  </div>

  <div>
    <%= form.label :due_on, "期限" %>
    <%= form.date_field :due_on, data: { controller: "datepicker", action: "turbo:before-cache@document->datepicker#teardown" } %>
  </div>

  <fieldset>
    <legend>タグ</legend>
    <%= form.collection_check_boxes :tag_ids, Tag.order(:name), :id, :name %>
  </fieldset>

  <%= form.submit "保存", data: { turbo_submits_with: "送信中…" } %>
<% end %>

目で見るユーザーには色や位置でエラーが分かりますが、スクリーンリーダーのユーザーには、こうした属性がないと伝わりません。

25.8 URL は基本不変にする

フォームのやり直しでは、URL は変えません。失敗しても成功しても、ユーザーは同じ画面で操作を続けます。検索(第23章)のように URL に状態を残す必要はありません。advance のような URL 操作は、フォームには使わないのが基本です。

25.9 テスト

フォームは、次の 3 つを確かめます。

  • 無効な入力で、エラーが同じ画面に出る(ページ遷移しない)
  • 有効な入力で、成功し、一覧が更新される
  • 二重送信が防がれる(送信中にボタンが無効になる)

System Test で、無効入力時にフォームが残りエラーが出ること、有効入力時に一覧へ追加されることを確認します。第16章で書いたテストに、エラー表示の確認(role="alert" の存在など)を足すとよいでしょう。

25.10 アンチパターン

  • 失敗時に 422 ではなく 200 で render する。Drive の非 GET 送信では HTML が再描画されません。Frame / Stream でも成功と失敗を区別するため 422 にします(第8章)。最頻出のつまずきです。
  • JavaScript の検証だけにする。クライアント側の検証は補助です。サーバー検証を省くと、簡単にすり抜けられます。
  • エラー時にフォーカスが迷子になる。再描画後、フォーカスがどこにあるか分からないと、キーボードやスクリーンリーダーのユーザーが迷います。エラーサマリへフォーカスを移します。

第25章では、フォームのエラー表示・送信中・二重送信・a11y を仕上げました。次の第26章では、モーダル・タブ・ドロップダウンを題材に、「サーバーの状態が要るか」で実装が分かれることを学びます。

25.11 同じモデルのフォームを複数置くとき

完成版のフォームは context(page / inline / edit / modal)と dom_id(task) を namespace に使います。これにより一覧の新規フォームとモーダルが同時に存在しても、入力 ID と aria-describedby が重複しません。エラーサマリには件数だけでなく各エラーの本文を表示します。ページ形式の送信はリダイレクトを使うので、25.8 の「URL 不変」は inline / frame / stream の部分更新に限る説明です。

参考資料

第26章 モーダル、タブ、ドロップダウン

この章のねらい

モーダル、タブ、ドロップダウン。どれも見慣れた UI ですが、Hotwire では「何で作るか」が一つに決まりません。サーバーの内容が要るかどうかで、実装が分かれるからです。

この章では、その仕分けを学びます。サーバーの内容が要らないものは Stimulus だけで、サーバーから内容を取るものは Turbo Frames や Turbo Streams で作ります。

26.1 完成イメージ

  • ドロップダウン … ボタンを押すとメニューが開く。中身は最初からある
  • タブ … 切り替えで表示が変わる。中身が最初からある「静的タブ」と、開いたときにサーバーから取る「遅延タブ」がある
  • モーダル … 「新規作成」を押すと、サーバーからフォームを取って重ねて表示する

26.2 この章の選択

判断の軸は「サーバーの状態(内容)が要るか」です。

  • ドロップダウンや静的タブは、中身が最初からページにあります。開閉や切り替えはサーバー往復が要りません。だから Stimulus だけで作ります。
  • 遅延タブやモーダルは、中身をサーバーから取ります。だから Turbo Frames や Turbo Streams を使います。

同じ「重ねて表示する」見た目でも、中身がどこから来るかで道具が変わります。

サーバー状態の要否で道具を選ぶ。サーバー内容が要らない(ドロップダウン・静的タブ=Stimulus)か、要る(遅延タブ・モーダル=Frames/Streams)かを仕分ける判断フローチャート

26.3 ドロップダウンを Stimulus だけで作る

ドロップダウンは、サーバーと関係ありません。Stimulus だけで作ります。

app/javascript/controllers/dropdown_controller.js

app/javascript/controllers/dropdown_controller.js

import { Controller } from "@hotwired/stimulus"

// 第26章: サーバー不要のドロップダウン
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 (event.key === "Escape" || !this.element.contains(event.target)) {
      this.menuTarget.hidden = true
      this.buttonTarget.setAttribute("aria-expanded", "false")
      if (event.key === "Escape") this.buttonTarget.focus()
    }
  }
}
<div data-controller="dropdown" data-action="click@window->dropdown#close keydown.esc@window->dropdown#close">
  <button type="button" data-dropdown-target="button" data-action="dropdown#toggle"
          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 aria-label="プロジェクト内の表示">
  <%= 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 %>

この遅延表示は通常のリンクナビゲーションとして実装し、ARIA の tab ロールは付けません。tab を名乗るには選択状態や矢印キー操作も必要だからです。各リンクの行き先が id="tab_content" の frame を返します(第13章)。中身をサーバーから取るので、Stimulus 単独ではなく Turbo Frames です。

26.6 モーダルを Turbo Frames と <dialog> で作る

modal frame + dialog + Stimulus open + 成功時 Streams close の流れを示す図。

モーダルは、サーバーからフォームを取って重ねます。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

<% if turbo_frame_request_id == "modal" %>
  <%= turbo_frame_tag "modal" do %>
    <dialog aria-labelledby="new_task_heading" data-controller="modal" data-action="close->modal#cleanup turbo:before-cache@document->modal#cleanup">
      <h2 id="new_task_heading">新規タスク</h2>
      <button type="button" data-action="modal#close">閉じる</button>
      <%= render "form", task: @task, context: "modal" %>
    </dialog>
  <% end %>
<% else %>
  <h1>新規タスク</h1>
  <%= render "form", task: @task, context: "page" %>
<% end %>

app/javascript/controllers/modal_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.showModal()
  }

  close() { this.element.close() }
  cleanup() { this.element.remove() }

  disconnect() {
    // Validation may replace the dialog. Do not steal focus from its replacement.
    if (!document.querySelector("dialog[open]")) {
      document.getElementById("new_task_modal_link")?.focus()
    }
  }
}

turbo_frame_request_id == "modal" のときだけ modal frame を返します。URL を直接開いたときは普通のフォームを返すため、レイアウトの空 frame と ID が重複しません。起点のリンクには id: "new_task_modal_link" を付けます。dialog の削除後は、このリンクへフォーカスを戻します。

エラー時も modal frame を HTML 422 で返す必要があります。第24章の create はその分岐を inline より先に判定します。inline 用の new_task_form だけを更新すると、モーダルのエラーが見えません。

26.7 成功時に Streams でモーダルを空にする

モーダルの中のフォームを送信して成功したら、モーダルを閉じ、一覧を更新します。これは複数箇所の更新なので、Turbo Streams です(第16章)。

app/views/tasks/create.turbo_stream.erb(成功時)

app/views/tasks/create.turbo_stream.erb

<%= turbo_stream.update "new_task_form" do %>
  <%= render "form", task: Task.new, context: "inline" %>
<% end %>
<%= turbo_stream.update "modal" %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

モーダルを空にし、フォームとフラッシュを更新してから一覧の変更通知を渡します。検索条件に合わない行を無条件で追加しません。失敗時はモーダル内のフォームへエラーを返します。

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部を締めます。

26.12 遅延表示のルートとビュー

第13章の resources :projects の member に get :overview と get :tasklist を追加します。コントローラの set_project の対象にも二つのアクションを追加します。

def overview; end

def tasklist
  @tasks = @project.tasks.includes(:assignee, :tags).order(id: :desc)
end

overview.html.erb は turbo_frame_tag "tab_content" で @project.description を囲み、tasklist.html.erb は同じ frame の中で render @tasks を呼びます。プロジェクト詳細に第13章の別のタスク一覧を同時に置くと task_1 などが重複するため、完成版では遅延ナビゲーションの一か所だけへ表示します。

26.13 静的タブの完全な例

静的タブは次のコントローラで選択状態・tabindex・パネル表示を一緒に変更します。左右矢印・Home・End に対応します。ID の接頭辞は同じページで重複しないものを使います。

app/javascript/controllers/tabs_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["tab", "panel"]
  select(event) { this.activate(this.tabTargets.indexOf(event.currentTarget)) }
  navigate(event) {
    const index = this.tabTargets.indexOf(event.target)
    let next
    if (event.key === "ArrowRight") next = (index + 1) % this.tabTargets.length
    else if (event.key === "ArrowLeft") next = (index - 1 + this.tabTargets.length) % this.tabTargets.length
    else if (event.key === "Home") next = 0
    else if (event.key === "End") next = this.tabTargets.length - 1
    else return
    event.preventDefault()
    this.activate(next)
    this.tabTargets[next].focus()
  }
  activate(index) {
    this.tabTargets.forEach((tab, i) => {
      tab.setAttribute("aria-selected", String(i === index))
      tab.tabIndex = i === index ? 0 : -1
      this.panelTargets[i].hidden = i !== index
    })
  }
}
<div data-controller="tabs">
  <div role="tablist" aria-label="説明" data-action="keydown->tabs#navigate">
    <button type="button" id="help-tab-a" role="tab" aria-controls="help-panel-a" aria-selected="true" tabindex="0" data-tabs-target="tab" data-action="tabs#select">概要</button>
    <button type="button" id="help-tab-b" role="tab" aria-controls="help-panel-b" aria-selected="false" tabindex="-1" data-tabs-target="tab" data-action="tabs#select">操作</button>
  </div>
  <div id="help-panel-a" role="tabpanel" aria-labelledby="help-tab-a" tabindex="0" data-tabs-target="panel">概要の本文</div>
  <div id="help-panel-b" role="tabpanel" aria-labelledby="help-tab-b" tabindex="0" data-tabs-target="panel" hidden>操作の本文</div>
</div>

ドロップダウン例は、通常のリンク一覧を開閉する disclosure として扱います。aria-haspopup="menu" や menu ロールは付けません。メニュー型にするなら矢印キーなど別のキーボード規約も実装します。

参考資料

第27章 通知、トースト、フラッシュメッセージ

この章のねらい

第7部の締めは、通知です。操作の結果を伝えるフラッシュ、少し経つと消えるトースト、そして他のユーザーの操作を知らせるリアルタイム通知。

この章は、これまで学んだものの合わせ技です。Turbo Streams で差し込み、Stimulus で演出し、Action Cable で配信します。第5部・第6部・第18章が、ここで一つにつながります。

27.1 完成イメージ

操作するとトーストが画面の隅に出て、数秒後に自動で消えます。閉じるボタンでも消せます。複数の通知は積み重なります。さらに、他のユーザーが自分にタスクを割り当てると、その通知がリアルタイムで届きます。

27.2 この章の選択

Streams による差し込み、Stimulus による演出、Action Cable による他者発配信の役割分担を示す図。

役割を 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 %>" data-turbo-temporary><%= 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" %>

コントローラでは flash.now を使います(ページ遷移しないため、第16章)。これで、操作のたびにフラッシュがその場に出ます。

27.5 Stimulus で自動消滅・閉じる・transition

フラッシュを「少し経つと消えるトースト」にするのは、Stimulus の役割です。第21章で作ったトーストのコントローラを、閉じるボタンに対応させて使います。

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" data-turbo-temporary>
  保存しました。
  <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 コントローラを持つので、各自が独立して時間で消えます。#toasts を aria-live 領域にしておけば、追加されたトーストが読み上げられます。

27.7 Action Cable でリアルタイム通知に拡張する

ここまでは「自分の操作」への通知でした。最後に、「他のユーザーの操作」への通知を足します。第18章の broadcast を使います。

各ユーザーのページが、自分宛ての通知を購読します。レイアウトに置きますが、Rails 標準認証では authenticated? でセッションを復元して判定し、Current.user を参照するので、ログイン時だけ描きます。

<% if authenticated? %>
  <%= 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 を、テスト・デバッグ・性能・セキュリティの面から保守します。

27.11 配信用 partial と callback

app/views/toasts/_toast.html.erb を次の内容で作成します。data-turbo-temporary は空の受信コンテナではなく、消したい個々のメッセージに付けます。

app/views/toasts/_toast.html.erb

<%# 第21/27章: 自動で消えるトースト %>
<div data-turbo-temporary class="toast" data-controller="toast" data-toast-delay-value="5000">
  <%= message %>
  <button type="button" data-action="toast#dismiss" aria-label="閉じる">×</button>
</div>

27.7 の送信コードは、Task の after_create_commit と after_update_commit から呼びます。更新では saved_change_to_assignee_id? のときだけ送ります。担当者を変更していない保存まで通知しないためです。完成したモデルは付録Gに掲載します。

参考資料

ハンズオン(第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部)です。

アクセシビリティの確認

  • ラベルと入力が対応し、同時に開いたフォームでも ID が重複しない。
  • タブ・ドロップダウン・ダイアログをキーボードだけで開閉できる。
  • エラーの件数だけでなく本文を読め、エラーサマリへフォーカスが移る。
  • ダイアログを閉じると起点へフォーカスが戻る。
  • 検索中に入力フォーカスを奪わず、IME の変換途中で送信しない。
  • 結果の全文ではなく件数を読み上げ、通知を重要情報の唯一の表示にしない。
  • 追記はボタンでも操作でき、ページ送り方式へ切り替えられる。

自動テストに加え、ブラウザのキーボード操作とスクリーンリーダーで確認します。ARIA 属性があるだけで使いやすさを保証できるわけではありません。

第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)が動いて初めて成立します。

サーバー側のテストだけでは、ここを確かめきれません。コントローラが正しい Turbo Streams を返しても、それがブラウザで正しく適用されるか、Stimulus のコントローラが結びついて動くか、までは見られないからです。

だから、ブラウザを動かす System Test が要ります。System Test は、実際のブラウザ(ヘッドレスの Chrome など)で画面を操作し、Hotwire の結合部を確かめます。

28.2 テストの配分

とはいえ、何でも System Test で確かめるのは重すぎます。System Test は遅く、壊れやすいからです。テストは、層で分けて配分します。

  • モデルのテスト … バリデーション、enum、検索のスコープなど、サーバー側のロジック。速くて確実なので、ここで手厚く。
  • リクエストのテスト … コントローラが返すステータスや形式。422 が返るか(第8章)、turbo_stream 形式で応答するか、検索パラメータで正しく絞り込むか(第23章)。条件の組み合わせは、ここで網羅。
  • System Test … 上の積み重ねが、ブラウザで結合して動くか。インライン編集の差し替え、Streams の追加、Stimulus の振る舞いなど、JavaScript が絡む経路に絞る。

第7部までで各章に書いてきた小さなテストは、この配分の中に収まります。「条件は下の層で、結合は System Test で」と分けると、軽く確実なテストになります。

テストの配分(ピラミッド)。下からモデル(多い・速い)・リクエスト(中)・System(少ない・遅い)を積み、Hotwire の結合を System で守ることを示すピラミッド図

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 "保存"
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章では、テストでは捉えきれない不具合を追うための、デバッグとイベント観察の道具を整えます。

28.10 完成版の回帰テストへ更新する

共通基底も完成版では購読完了の待機を追加します。第5章の基底を次へ更新します。

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  include ActionView::RecordIdentifier
  driven_by :selenium, using: :headless_chrome, screen_size: [ 1200, 1000 ]

  Capybara.default_max_wait_time = 10

  teardown do
    errors = page.driver.browser.logs.get(:browser).select { |entry| entry.level == "SEVERE" && !entry.message.include?("422") }
    puts errors.map(&:message) if errors.any?
  end

  setup do
    visit new_session_path
    fill_in "email_address", with: users(:one).email_address
    fill_in "password", with: "password"
    click_on "Sign in"
    assert_selector "h1", text: "タスク"
    assert_current_path root_path
    assert_selector "turbo-cable-stream-source[connected]", visible: :all
  end
end

第8・12・16・20章のテストは、その時点の UI を確かめるものです。完成版では以下へまとめ、古いラベルや行の追加方式を前提にするテストは置き換えます。

test/system/tasks_test.rb

require "application_system_test_case"

class TasksTest < ApplicationSystemTestCase
  test "inline create and validation preserve the page" do
    page.execute_script("window.relayTestMarker = 'unchanged'")
    within "#new_task_form" do
      select projects(:one).name, from: "プロジェクト"
      click_on "保存"
      assert_selector ".error-summary"
      fill_in "タイトル", with: "System new task"
      click_on "保存"
    end
    within "#tasks" do
      assert_text "System new task"
      assert_selector "turbo-frame:first-child", text: "System new task"
    end
    assert_equal "unchanged", page.evaluate_script("window.relayTestMarker")
  end

  test "modal errors stay inside dialog and success closes it" do
    click_on "新規作成(モーダル)"
    within "dialog[open]" do
      select projects(:one).name, from: "プロジェクト"
      click_on "保存"
      assert_selector ".error-summary"
      fill_in "タイトル", with: "Modal task"
      click_on "保存"
    end
    assert_no_selector "dialog[open]"
    within("#tasks") { assert_text "Modal task" }
    assert_selector "#new_task_modal_link:focus"
  end

  test "inline editing and deleting work" do
    task = tasks(:one)
    within "##{dom_id(task)}" do
      click_on "編集"
      fill_in "タイトル", with: "Edited inline", fill_options: { clear: :backspace }
      assert_field "タイトル", with: "Edited inline"
      click_on "保存"
    end
    assert_selector "##{dom_id(task)}", text: "Edited inline"
    assert_no_selector "#task_list[busy]"
    within "##{dom_id(task)}" do
      accept_confirm { click_on "削除" }
    end
    assert_no_selector "##{dom_id(task)}"
  end

  test "search and pagination preserve filters" do
    visit project_path(projects(:one))
    24.times { |n| projects(:one).tasks.create!(title: "Needle #{n}") }
    visit tasks_path
    page.document.synchronize do
      assert page.evaluate_script("!!window.Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller=search]'), 'search')")
    end
    fill_in "タイトルで検索", with: "Needle", fill_options: { clear: :backspace }
    assert_field "タイトルで検索", with: "Needle"
    within("#task_list") { assert_text "24 件該当" }
    click_on "次へ"
    assert_current_path tasks_path(q: "Needle", status: "", project_id: "", tag_id: "", mode: "pages", page: "2")
    within("#tasks") { assert_selector "turbo-frame", count: 4 }
  end

  test "more mode appends all pages without duplicate ids" do
    visit project_path(projects(:one))
    43.times { |n| projects(:one).tasks.create!(title: "More #{n}") }
    visit tasks_path(q: "More", mode: "more")
    within("#tasks") { assert_selector "turbo-frame", count: 20 }
    within("#pagination") { click_on "もっと読む" }
    within("#tasks") { assert_selector "turbo-frame", count: 40 }
    within("#pagination") { click_on "もっと読む" }
    within("#tasks") { assert_selector "turbo-frame", count: 43 }
    assert_no_selector "#pagination a"
    ids = all("#tasks turbo-frame").map { |node| node[:id] }
    assert_equal ids.uniq, ids
  end

  test "comments validate and append" do
    visit task_path(tasks(:one))
    within "##{dom_id(tasks(:one), :comment_form)}" do
      click_on "コメント"
      assert_selector "[role=alert]"
      fill_in "コメント本文", with: "System comment"
      click_on "コメント"
    end
    assert_text "System comment"
  end
end

HTTP ステータス、検索の % / _、HTML と Stream の形式はリクエストテストで検証します。System Test では JavaScript の印が残ること、対象行の表示・フォーム消去・URL・追記件数を確認します。配信を含む場合は購読完了を待ち、Cable のアダプタもブラウザに配信する設定にしてください。

参考資料

第29章 デバッグとイベント観察

この章のねらい

Hotwire のアプリがうまく動かないとき、「どこで差し替えが止まったのか」を切り分けられると、解決が早くなります。この章では、その観察の道具を体系化します。

闇雲にコードを直す前に、観察します。順番は、Network → Turbo のイベント → Stimulus の接続 → Frame / Stream の target → morph、です。上から順に見れば、たいていの不具合は場所を特定できます。第28章のテストでは捉えきれない不具合を、ここで追います。

デバッグの切り分け順。Network→Turbo イベント→Stimulus 接続→target id→morph の順で、各ステップの「見るもの」と「分かること」を示すフローチャート

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 コントローラの接続を確認する

Stimulus が動かないときは、コントローラが結びついているかを確かめます。第6章で見たとおり、application.debug を true にすると、コントローラの接続・切断がログに出ます。

// app/javascript/controllers/application.js
application.debug = true

接続のログが出なければ、data-controller の名前とファイル名がずれている(第19章)、ファイルの置き場所が違う、といった原因が疑えます。window.Stimulus から、登録されたコントローラを確認することもできます。

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 が動かない … コントローラの接続ログが出ない(29.3)。
  • 更新の命令が効かない … stream の target が存在しない(29.4)。

代表的なエラーと対処は、付録Eにまとめます。困ったら、まず Network、次に Turbo イベント、Stimulus、target、morph。この順で見れば、たいていの不具合は場所が分かります。

第29章では、不具合の場所を切り分ける観察の道具を整えました。次の第30章では、Hotwire の体感速度を損なう、Rails 側のパフォーマンス問題(とくに N+1)を扱います。

参考資料

第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

一覧描画で N+1 が起きる様子と、includes で解消する様子をクエリ本数で対比する図。

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 の描画結果をキャッシュし、変わっていなければ作り直しません。

<%# app/views/tasks/_task.html.erb の中。既存の表示部分を囲みます。 %>
<% cache [task, task.assignee, task.tags.order(:id).to_a] do %>
  <%# この中に turbo_frame_tag task と既存の表示コードを置きます。 %>
<% end %>

これはキャッシュを採用する場合の別案です。_task 内で render task を呼ぶと自分自身を再帰描画するため、実際の表示コードを囲みます。partial 内に置くので一覧からも配信からも同じキャッシュを使えます。

キーは task 本体だけでなく表示に使う担当者・タグの変更も含めます。関連を追加・削除したときもキーの構成が変わります。ユーザー別のボタンなどを追加したら、その権限やユーザーによる差もキーに反映する必要があります。キー計算のクエリまで含めて測定し、改善が確認できなければ導入しません。完成版は表示を古くするリスクを避け、このフラグメントキャッシュを有効にしていません。

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部を締めます。

参考資料

第31章 認証、認可、セキュリティ

この章のねらい

Hotwire は、画面の更新方法を変えますが、セキュリティの責任は変えません。部分更新でも broadcast でも、「誰が何をしてよいか」を守るのは、通常の Rails と同じです。

この章では、Hotwire を使うときに崩しやすい認可の落とし穴と、その守り方を学びます。第8部の軸「Hotwire は危ない Rails を隠さない」の総仕上げです。本書のサンプル Relay は単一チーム前提なので、ここは要点を押さえる範囲にとどめ、考え方を示します。

31.1 コントローラの認可を省略しない

Hotwire を使っても、リクエストはコントローラを通ります。frame の読み込みも、Turbo Streams を返す送信も、すべて通常のコントローラアクションです。

だから、認可はコントローラで行います。「ログインしているか」「その操作をしてよいユーザーか」を、これまでどおりコントローラで確認します。Hotwire だから特別なことをする、のではありません。むしろ、Hotwire だからといって認可を省略してよい場所は 1 つもありません。

31.2 Frame / Stream でも権限を確認する

つまずきやすいのは、「これは部分的な HTML だから」と気を抜くことです。

frame の src が指すアクションも、Turbo Streams を返すアクションも、独立したリクエストです。一覧ページで認可していても、frame が読み込むアクションを直接叩かれたら、そのアクションが無防備なら通ってしまいます。

部分を返すアクションにも、ページを返すアクションと同じ認可をかけます。「ページ全体は守ったから安心」ではなく、「部分を返す入口も同じく守る」と考えます。

31.3 broadcast の配信範囲

リアルタイム更新(第18章)では、配信範囲が認可と直結します。

broadcast は、購読している全員に届きます。配信先(streamable)を広く取りすぎると、見せてはいけない相手にまで更新が飛びます。たとえば、あるプロジェクトの更新を全ユーザー共通の配信先に流せば、無関係なユーザーにも届きます。

配信先は、「その更新を見てよい範囲」に合わせます。プロジェクト単位で見せるなら、プロジェクトを配信先にします。そして、配信する内容に、見せてはいけない情報を含めない。配信は受け取った全員の画面に出ることを、常に意識します。

31.4 署名付き stream 名への購読

署名付き stream 名、配信範囲、認可が別レイヤーであることを示す図。

ここで、誤解しやすい点をはっきりさせます。turbo_stream_from が作る購読名は、署名されています。これは購読名の改ざんを防ぐもので、第三者が購読名を推測・改変して別の配信を盗み聞きすることを、難しくします。

しかし、これは認可ではありません。署名は「購読名が正規のものか」を保証するだけで、「そのユーザーがそれを見てよいか」は判断しません。

アクセス制御は、別途行います。見てよいユーザーにだけ turbo_stream_from を描く(第27章で authenticated? が真のときだけ購読したのも、その一例)、配信内容に秘密情報を含めない、配信元のコントローラ / 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 を推測されても問題ないよう、コントローラで認可します。

ユーザーごとに分けたい配信では、推測しやすい共通の id ではなく、署名付きの stream 名(31.4)で購読を分けます。id の付け方そのものは推測されうる前提で、その上で認可と署名で守る、と考えます。

31.7 第18章との責務分担

第18章では、broadcast の「仕組み」を扱いました。turbo_stream_from で購読し、broadcasts_to や broadcast_*_to で配信する、という配信の仕方です。

この章は、その上の「責任」を扱いました。配信先を正しく絞る、内容に秘密を含めない、署名は認可ではないと理解する、コントローラで認可する。仕組みが分かったうえで、誰に何を届けてよいかを設計するのが、この章の役割です。

Relay は単一チーム前提なので、実装は最小限です。しかし、マルチテナントや権限の分かれるアプリでは、ここが設計の中心になります。Hotwire の部分更新・broadcast を足しても、認可の原則は変わらない。これが、第8部を通じての結論です。

第31章で、第8部を締めます。テスト・デバッグ・性能・セキュリティと、育てた Relay を保守する観点を見てきました。次の第9部では、この Relay を、書き直さずにモバイルへ広げる Hotwire Native を学びます。

31.8 認証切れと購読の失効

セッション切れで frame の要求がログインへ転送されると、対応する frame がなく Content missing になります。app/views/sessions/new.html.erb の先頭に <% turbo_page_requires_reload %> を置き、ログイン画面をページ全体として表示します。

購読タグを描いた時点の認可だけでは、接続後のログアウトや権限剥奪に対応できません。署名済み stream 名は、それを保持する相手から再利用される可能性もあります。機密データを配信する製品では独自 Channel で購読時に認証・認可し、接続識別子と失効処理を設計します。セッション失効時には対象接続を切断し、以降の再接続も拒否する必要があります。

Relay は単一チームの教材で、この失効機構を実装した製品ではありません。共有するタスク変更通知はデータを含めず、HTTP での再取得時に認証します。コメント・担当者通知を本番利用する際は、上記の認可と失効処理を先に追加してください。

参考資料

ハンズオン(第8部): System Test で Hotwire UI を検証する

第7部までに作った Relay を、テスト・性能・セキュリティの面から固めます。

この部の到達状態

  • 主要フローを覆う System Test 一式がある
  • assignee / tags / comments の N+1 を includes で解消してある
  • broadcast の配信先をコントローラ / model 側で絞り、購読は署名付き stream 名で行っている(署名は購読名の改ざん防止であって認可ではない)
  • 「Network → Turbo イベント → Stimulus → target id」の順で原因を切り分けられる

作る・変える

  1. テスト戦略に沿って層を配置し、フレークしない System Test を書く
  2. N+1 をログで計測してから解消する
  3. broadcast の配信先をコントローラ / 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 で実現していた高速な画面遷移が、ネイティブのナビゲーションと組み合わさります。

WebView と native shell。スマホの外枠の中に、ネイティブのナビゲーションバー(shell)と Relay の Web 画面を表示する WebView を配した構成図

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 を学びます。

WebView は Rails の cookie セッション認証を利用できますが、Safari や Chrome など外部ブラウザの cookie が自動共有されるという意味ではありません。WebView 内でログインし、そこで発行された cookie を使います。複数 WebView 間の共有も、プラットフォーム側のデータストア設定に依存します。

参考資料

第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 context と presentation を区別する

URL パターンとネイティブの提示方法を rules が対応づける図。

ルールのプロパティで、画面の出し方を指定します。代表的なのが、モーダルで出すか、プッシュで出すか、です。

上の例の "context": "modal" は、その URL をモーダルとして出す指定です。タスクの新規作成や編集を、モーダルで重ねて表示できます。指定がなければ、画面はナビゲーションに積まれる通常のプッシュ遷移になります。

context は default / modal のナビゲーションの文脈です。presentation は default / push / pop / replace / refresh / clear_all / none などの提示操作で、modal を指定するキーではありません。ルールは順に適用し、後の一致が同じキーを上書きします。大切なのは、「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 を学びます。

参考資料

第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 のコントローラの上に乗ります。

Web 側では、data-controller でブリッジ用のコントローラを要素に結びつけます。

<form data-controller="bridge--form">
  ...
</form>

この bridge--form というコントローラが、ネイティブ側へ「送信ボタンを出して」と伝えるブリッジになります。ブリッジ用のコントローラは、Stimulus のコントローラを、ネイティブと通信できるように拡張したものです(34.6 の BridgeComponent を使います)。

大切なのは、Web 側はあくまで Stimulus の延長で書ける、という点です。第6部で学んだコントローラの知識が、そのまま土台になります。

34.3 ネイティブ側の component

Web 側の宣言を受け取って、実際にネイティブの UI を出すのが、ネイティブ側の component です。

iOS なら Swift、Android なら Kotlin で書きます(第32章)。Web 側の bridge--form コントローラに対応するネイティブの component を用意し、「bridge--form が現れたら、ナビゲーションバーに送信ボタンを出す」といった処理を書きます。

対応のキーは Stimulus 識別子そのものではなく、Web 側の static component とネイティブ側の component 名です。たとえば識別子 bridge--form、static component = "form" ならネイティブ側は form に合わせます。Web の宣言と、ネイティブの実装が、ペアになっている、と考えてください。

34.4 メッセージの送受信

Web の Stimulus とネイティブ component が名前で対応し、メッセージを往復する図。

Web とネイティブは、メッセージをやり取りします。

  • Web → ネイティブ。「送信ボタンを、このラベルで出して」といった指示を送ります。ボタンのラベルや状態など、必要な情報を渡します。
  • ネイティブ → Web。「ボタンが押された」といった出来事を伝えます。Web 側はそれを受け取り、フォームを送信するなどの処理をします。

この往復で、Web の状態(フォームの内容など)と、ネイティブの操作(ボタンのタップ)が、つながります。Web 側の受け取りは、ブリッジ用コントローラのメソッドとして書きます。ここも 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部を締めます。

34.6 名前の対応をコードで確認する

次は通信の概念を示す Web 側の断片です。通常の Stimulus Controller ではなく BridgeComponent を使います。bin/importmap pin @hotwired/hotwire-native-bridge で導入し、ネイティブ側にも form component を登録して初めてネイティブのボタンが現れます。Web の送信ボタンは残します。

// app/javascript/controllers/bridge/form_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
  static component = "form"
  static targets = ["submit"]
  submitTargetConnected(button) {
    this.send("connect", { submitTitle: button.textContent }, () => button.click())
  }
}
<form data-controller="bridge--form">
  <button type="submit" data-bridge--form-target="submit">保存</button>
</form>

ネイティブ実装・登録方法は iOS Bridge Components と Android Bridge Components を参照します。付録Hの最小シェルは Bridge なしで Web の CRUD を開く範囲です。

参考資料

第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 のタスク一覧が同じアプリ内に混在し、認証セッションを WebView へ渡す図。

ネイティブ画面と Web 画面が混在するとき、いちばん注意が要るのが状態の同期、とくに認証です。

たとえば、ネイティブのログイン画面で認証したとします。その結果(ログイン済みであること)を、Web 側の WebView にも引き継がないと、Web 画面では「ログインしていない」ことになってしまいます。

WebView は、Web のセッション(cookie)を使います(第32章)。だから、ネイティブで認証した結果を、WebView のセッションに反映する受け渡しが要ります。ここは、第31章で見た認証・セキュリティの考え方が、ネイティブとの境界でも問われる場面です。トークンやセッションの受け渡しを、安全に設計します。付録Hは WebView 内で既存の Web ログインを使います。ネイティブ認証と Web セッションの交換プロトコルは本書の実装範囲外です。

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 を選ぶべきか」を考えます。

参考資料

演習(第9部): ネイティブ化の構成を設計する

実機ビルドの手順は付録Hで扱います。この演習は、ネイティブ開発環境がなくても取り組める「設計」に絞ります。

この部の到達状態

  • Relay のどの URL をモーダル / プッシュで見せるかを Path Configuration として設計できる
  • どの画面を Web のまま残し、どこをネイティブ画面にするかを判断できる
  • Bridge Component が必要になる箇所を 1 つ挙げて理由を説明できる

演習

  1. Relay の主要 URL に context(modal / default)を割り当てる
  2. ネイティブ化の候補(ログインなど)と、Web のまま残す画面を仕分ける
  3. 状態同期(認証・セッション)で注意する点を、第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 コントローラが分岐だらけになる

「frame からのリクエストか」「Turbo Streams を返すか」で、コントローラが if 分岐だらけになることがあります(第14章)。

多くの場合、同じビューに frame を置いておけば、Turbo が必要な部分を取り出してくれます。まずは、リクエストの種類で分岐を増やさずに済まないかを考えます。分岐が増えてきたら、それは設計を見直す合図です。

36.3 Stimulus に状態管理を押し込む

Stimulus に、アプリの状態をため込みたくなることがあります。コントローラのインスタンス変数に、あれこれ持たせる形です。

しかし、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 の入れ子と格闘している、コントローラが分岐だらけ、Stimulus に状態を詰め込んでいる。そんなときは、その画面が本当に Hotwire 向きだったかを問い直します。ふつうのページ遷移(Turbo Drive のまま)で十分なら、そこに戻すのが正解です。

判断の基準は、繰り返しになりますが、「できるか」ではなく「読みやすく保てるか」です。Hotwire は道具であって、目的ではありません。

第36章では、Hotwire のアンチパターンを総まとめしました。次の第37章では、視野を広げて、React / Vue などの SPA と Hotwire の使い分けを考えます。

参考資料

第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 など)があり、「サーバー描画かクライアント描画か」の線は実際にはぼやけています。ここでは大づかみの違いとして捉え、細部はそれぞれの技術で確認してください。

Hotwire と SPA の比較軸。描画・状態・通信・JavaScript 量の 4 軸上で両者の位置を対比し、SSR メタフレームワークで境界がぼやけることも示すスペクトラム図

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 採用判断チェックリスト

CRUD 中心か、クライアント状態、オフライン、チーム、API 要否の問いから、Hotwire 向きか SPA/混在向きかを導く図。

最後に、判断の目安をまとめます。次に多く当てはまるほど、Hotwire が向きます。

  • データの一覧・作成・編集が中心である
  • クライアント側に持つ状態は、それほど大きくない
  • オフライン対応は要らない
  • チームは Rails に強く、JavaScript は少なくしたい
  • Web のためだけなら、JSON API は必須ではない

逆に、クライアント状態が大きい・オフラインが要る・すでに API 中心、に多く当てはまるなら、SPA や混在を検討します。Hotwire は万能ではありませんが、多くの業務アプリにとって、十分で、保守しやすい選択肢です。

第37章では、Hotwire と SPA を選択肢として比較しました。次の第38章では、Hotwire の現在地と今後を見て、本書を締めます。

参考資料

第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 の実機ビルド手順を扱います。

参考資料

演習(第10部): 採用判断を言葉にする

本書のゴールは、Hotwire を「選ぶ/選ばない」を自分の言葉で判断できるようになることです。

この部の到達状態

  • Relay の中で「Hotwire で複雑になった箇所」を 1 つ挙げ、通常遷移や別設計への戻し方を説明できる
  • SPA との比較軸で Relay を評価できる

演習

  1. Relay の一部を取り上げ、Turbo Frames / Turbo Streams のやりすぎを指摘して、簡素化案を書く
  2. 同じ機能を SPA で作る場合との違いを、開発コストと保守コストの観点で比較する

Relay の現在地

Hotwire の採用可否を、根拠をもって判断できる状態。本書のゴール。

付録

本編を補う参照資料とハンズオンを置きます。

付録A 公式ドキュメントの歩き方

本書は、Hotwire の考え方と実践を一通り扱いました。しかし、Hotwire は動き続けます。新しい機能や、細かな仕様は、一次情報(公式ドキュメント)で確かめるのが確実です。この付録では、公式ドキュメントの歩き方を案内します。

一次情報の入口

Hotwire の公式情報は、次の場所にあります。

迷ったら、まずこの 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 で確認できます。

困ったときの調べ方

詰まったときは、次の順で当たると効率的です。

  1. エラーメッセージで、Reference や付録Eを引く。
  2. Handbook の該当章を読み直す。
  3. GitHub の Issues / Discussions で、同じ症状を探す。
  4. それでも分からなければ、最小の再現コードを作る。再現を作る過程で、原因に気づくこともよくあります。

一次情報を最優先に、確認日とバージョンを意識して調べる。これが、変化し続ける Hotwire と付き合うコツです。

付録B Turbo 属性・イベント一覧

本書で扱った Turbo の主な属性・meta タグ・イベントを、引きやすいようにまとめます。網羅的な一覧と最新の仕様は、公式リファレンスで確認してください。

主な 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-streamGET でも 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:clickTurbo 有効なリンクをクリックした第10章
turbo:before-visitvisit を始める直前(preventDefault で中断可)第10章
turbo:visitvisit を始めた第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-loadframe の読み込みが完了した第29章
turbo:frame-renderframe を描画した第29章
turbo:before-morph-element要素を morph する直前(preventDefault でスキップ可)第10章
turbo:morphmorph が終わった第10章

値の正確な指定や、ここに載せていない属性・イベントは、必ず公式リファレンスで確認してください。Turbo のバージョンによって追加・変更されることがあります。

付録C Turbo Streams アクション一覧

Turbo Streams の 8 つのアクションと、対応する Rails のヘルパーをまとめます。詳細と最新仕様は、公式リファレンスで確認してください。

8 つのアクション

アクション何をするかヘルパー例
appendtarget の末尾に追加するturbo_stream.append "tasks", @task
prependtarget の先頭に追加するturbo_stream.prepend "tasks", @task
replacetarget の要素自体を置き換えるturbo_stream.replace @task
updatetarget の中身だけを置き換えるturbo_stream.update "flash", partial: "layouts/flash"
removetarget を削除する(中身の HTML は不要)turbo_stream.remove @task
beforetarget の直前に挿入するturbo_stream.before "task_1", @task
aftertarget の直後に挿入する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 のソースで確認してください。

refresh のバージョン

refresh stream の method / scroll 属性は Turbo 8.0.21 以降です。Turbo 8 のすべての版で使える属性ではありません。

付録D Stimulus リファレンス

本書で扱った Stimulus の中心要素を、引きやすいようにまとめます。最新の仕様は公式リファレンスで確認してください。

ライフサイクルのコールバック

コールバックタイミング
initialize()コントローラのインスタンスが作られたとき(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.hasFieldTargetfield 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 で、結びついた別コントローラのインスタンスを参照します。HTML 側は data-<identifier>-list-outlet="#selector"。Outlet 先の要素が当該コントローラである必要があります(Stimulus 3.2 以降、第21章)。

Actions

HTML 側の書式は data-action="イベント->identifier#メソッド"。

  • 既定イベントのある要素(button の click、input / textarea の input、select の change など)は、イベントを省ける。
  • 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 Drive は、非 GET のフォーム送信へのリダイレクトなしの 200 HTML を描画せず、送信元の 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 のコントローラが動かない

症状: data-controller を付けたのに、振る舞いが動かない。

原因: コントローラ名とファイル名のずれ、ファイルの置き場所の誤り、target / action の名前の不一致(第19章・第20章)。

対処: application.debug = true にして、接続ログが出るか見る(第29章)。出なければ名前と置き場所、出れば target / action の名前を疑う。

戻る・進むで、古い内容や壊れた表示が出る

症状: 戻る操作で、古いフラッシュや、初期化済みのウィジェットが一瞬出る。

原因: 復元用のスナップショットに、消したい要素や JavaScript で書き換えた DOM が残っている(第9章・第22章)。

対処: 一度きりの要素には data-turbo-temporary を付ける。外部ライブラリは turbo:before-cache で後始末する。application visit のプレビューだけを止めるなら no-preview、戻る操作でのキャッシュ復元も止めるなら no-cache。

外部ライブラリが二重に初期化される

症状: 画面を行き来すると、ライブラリが重複して動く、残骸が残る。

原因: connect() で初期化したものを、disconnect() で破棄していない(第22章)。

対処: disconnect() で destroy() し、タイマーやリスナーも片付ける。

リアルタイム更新が届かない

症状: 他のユーザーの操作が、自分の画面に反映されない。

原因: 購読先(turbo_stream_from)と配信先(broadcasts_to / broadcast_*_to)の指す相手がずれている。または、update_all などの一括更新で callback が走っていない(第18章)。

対処: 購読先と配信先が同じ streamable を指しているか確認する。一括更新では broadcast が走らないことに注意する。

解決しないときは、最小の再現コードを作り、第29章の手順で 1 つずつ切り分けてください。

キャッシュと認証切れを切り分ける

no-preview は application visit のプレビューだけを止めます。戻る・進むで古いスナップショットを使わせたくないなら no-cache を選びます。

フレーム内操作がログインへ転送されて Content missing になる場合は、ログインビューに <% turbo_page_requires_reload %> を加えます。ログイン画面をフレームの外で表示させるための指定です。

付録F AI に Hotwire コードを依頼するときのプロンプト集

AI に Hotwire のコードを書いてもらうとき、前提を伝えないと、古いやり方や、本書と食い違う実装が返ってくることがあります。前提を添えると、精度が上がります。この付録では、依頼のコツとプロンプトの型を示します。

伝えるべき前提

AI への依頼には、次の前提を添えます。

  • Rails と Hotwire のバージョン(例: Rails 8.1.3、Turbo 8.0.21 以降、importmap 構成)。
  • フォームの契約(HTML の成功は redirect(status: :see_other)、部分更新の成功は Turbo Stream、失敗は status: :unprocessable_entity)。
  • 部分更新の方針(一覧の id、dom_id で宛先を揃える、partial を一覧と stream で共通化)。
  • 使わない方針(jsbundling ではなく importmap、状態は HTML に置く、など)。

これらは、本書で繰り返し出てきた約束ごとです。前提として渡すと、AI の出力が本書のやり方に揃います。

プロンプトの型

Rails 8 / Turbo 8 / importmap 構成です。次の前提を守ってコードを書いてください。
- HTML のフォーム成功は redirect(status: :see_other)。Turbo Streams の成功は stream レスポンス、失敗は 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、変更通知の broadcast第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)、ページネーション、該当総数 @total_count とページ内の @paged_tasks(第23章・第24章・第17章)。
  • create / update / destroy … 成功は redirect / Turbo Streams、失敗は 422(第8章・第16章)。format.turbo_stream と format.html の両方を持つ。

認可はコントローラで行います(第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.erbcreate / destroy などの stream 命令第16章
app/views/layouts/_flash.html.erbフラッシュ(#flash / aria-live)第27章

partial を一覧と stream で共通化したのが、設計の要でした(第12章・第17章)。

Stimulus コントローラ

ファイル役割章
autofocus_controller.js要素にフォーカス(エラーサマリにも再利用)第19章・第25章
counter_controller.js文字数カウンタ第20章
toast_controller.jsトーストの自動消滅・閉じる(Values の delay)第21章・第27章
search_controller.js入力の debounce 送信第23章
infinite_scroll_controller.jsIntersectionObserver で自動読み込み第24章
dropdown_controller.js / modal_controller.jsドロップダウン・モーダル第26章
chart_controller.js など外部ライブラリ連携第22章

初期化で確保するタイマー・外部ライブラリには破棄処理を対にし、状態は HTML 側に置きました(第6部)。

リアルタイム

  • turbo_stream_from(購読、ログイン時のみ)と broadcast_*_to(配信)でリアルタイム更新(第18章・第27章)。
  • 配信範囲と認可に注意(第31章)。

読み進め方

このアプリは、第2部で素の CRUD を作り、第3部以降で 1 つずつ Hotwire 化したものです。各ファイルを見るときは、対応する章に戻ると、「なぜそう書いたか」がたどれます。最初の素の状態から、段階を追って育てたことが、コード全体に表れています。

完成版の Relay のコード一式は、次のリポジトリで公開しています。

手元のコードと突き合わせながら読むと、各章の差分が追いやすくなります。

完成版モデルと差分の管理

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, validate: true

  validates :title, presence: true

  # 一覧条件は受信者ごとに異なるので、行を一律に配信しない。
  after_commit :announce_change
  after_create_commit :notify_assignee
  after_update_commit :notify_assignee, if: :saved_change_to_assignee_id?

  private

  def announce_change
    Turbo::StreamsChannel.broadcast_update_to :tasks, target: "task_changes",
      partial: "tasks/change"
  end

  def notify_assignee
    return unless assignee
    Turbo::StreamsChannel.broadcast_append_to assignee, target: "toasts",
      partial: "toasts/toast", locals: { message: "新しいタスクが割り当てられました。" }
  end
end

本文で掲載する完成版コードは manuscript/snippets/ に収録しています。scripts/sync_sample_snippets.py --source /path/to/Relay --check でサンプルとの一致を確認できます。更新時は --check を外して同期し、原稿の説明とテストも確認します。

第13章のサイドバー、第18章前半の行配信、第30章のキャッシュは設計を比較する別案です。完成版へすべて重ねて追加するものではありません。Native の最小構成は付録Hに分離し、Web アプリの検証と実機検証を区別します。

付録H Hotwire Native の最小アプリを起動する

この付録は WebView で Relay のログイン・一覧・詳細を開くための最小構成です。2026年10月9日に公式の Getting Started と照合しました。以下の Swift / Kotlin は構成例であり、本書の Web テストにネイティブの実機ビルドは含みません。署名付き実機配布やネイティブ認証連携まで完成したプロジェクトではありません。

H.1 Rails と接続先

bin/rails server -b 0.0.0.0
実行先開始 URL の例
Mac の iOS Simulatorhttp://localhost:3000
Android Emulatorhttp://10.0.2.2:3000
同じ LAN の実機http://開発MacのLANアドレス:3000

実機の localhost は実機自身です。実機から Mac へ届くネットワークとファイアウォール設定を確認します。Rails の Host Authorization で拒否された場合は、開発環境で使うホストを config.hosts に明示します。最初に実機のブラウザでアクセスできることを確認します。

H.2 iOS

Xcode の新規 iOS App で Swift / Storyboard を選びます。Package Dependencies に https://github.com/hotwired/hotwire-native-ios を追加してアプリのターゲットへ組み込みます。解決された版は Package.resolved に保存してください。

SceneDelegate.swift を次の形にします。

import UIKit
import HotwireNative

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private let navigator = Navigator(configuration: .init(
        name: "relay",
        startLocation: URL(string: "http://localhost:3000")!
    ))

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        window?.rootViewController = navigator.rootViewController
        navigator.start()
    }
}

ローカル HTTP を使う Debug 構成では、Info.plist の NSAppTransportSecurity 内に NSAllowsLocalNetworking を Boolean の YES で設定します。実機の LAN 接続で求められる場合は NSLocalNetworkUsageDescription に開発サーバーへ接続する理由を設定します。製品版のサーバーは HTTPS にし、開発用の広い HTTP 例外を持ち込みません。

Simulator を選び Product → Run で起動し、Relay の Web ログインを使います。iOS Getting Started はプロジェクトの作成から掲載しています。

H.3 Android

Android Studio の Empty Views Activity を選び、minimum SDK は API 28 以上、言語は Kotlin とします。アプリの build.gradle.kts に次を追加します。ここでは 1.3.1 に固定します。動的な + は使いません。

val hotwireVersion = "1.3.1"
dependencies {
    implementation("dev.hotwire:core:$hotwireVersion")
    implementation("dev.hotwire:navigation-fragments:$hotwireVersion")
}

AndroidManifest.xml の manifest 直下へ INTERNET 権限を追加します。

<uses-permission android:name="android.permission.INTERNET" />

ローカル HTTP の許可は src/debug/AndroidManifest.xml に限定します。

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <application android:usesCleartextTraffic="true" />
</manifest>

res/layout/activity_main.xml:

<androidx.fragment.app.FragmentContainerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_nav_host"
    android:name="dev.hotwire.navigation.navigator.NavigatorHost"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="false" />

MainActivity.kt(package は作成したアプリに合わせます):

package com.example.relay

import android.os.Bundle
import android.view.View
import androidx.activity.enableEdgeToEdge
import dev.hotwire.navigation.activities.HotwireActivity
import dev.hotwire.navigation.navigator.NavigatorConfiguration
import dev.hotwire.navigation.util.applyDefaultImeWindowInsets

class MainActivity : HotwireActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<View>(R.id.main_nav_host).applyDefaultImeWindowInsets()
    }
    override fun navigatorConfigurations() = listOf(
        NavigatorConfiguration(
            name = "relay",
            startLocation = "http://10.0.2.2:3000",
            navigatorHostId = R.id.main_nav_host
        )
    )
}

Gradle Sync 後、Emulator を選んで Run します。Android Getting Started と同じ最小構成です。依存版を選ぶ際には、そのリリースが必要とする Kotlin / Gradle / SDK も合わせて確認します。

H.4 動作確認と次の拡張

  1. WebView 内で alice@example.com / password でログインする。外部ブラウザのログイン cookie は自動共有されません。
  2. タスクの詳細へ進み、ネイティブの戻る操作で一覧へ戻る。
  3. 新規作成ページを直接開き、保存・エラー・ログアウトを確認する。
  4. 通信を止めたときのエラー表示を確認する。

モーダルの Path Configuration は第33章の JSON を使います。ファイルを置くだけでは読み込まれません。導入した版の iOS の設定読み込み または Android の設定読み込み に従って登録します。

Bridge を追加する場合は、iOS Bridge Components / Android Bridge Components の登録手順まで実施します。Web の static component とネイティブの登録名を一致させます。ログインを独自ネイティブ画面へ移す場合のトークン交換は、この最小構成には含めません。

おわりに

本書を最後まで読んでいただき、ありがとうございました。

本書では、チーム向けタスク管理アプリ 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 に限らず、これからの学習すべてで効いてきます。

もっと体系的に、仲間やメンターと一緒に学びたくなったら、ぜひのぞいてみてください。

主要な一次情報

最後に、実務で何度も戻ることになる主要な一次情報を挙げます。

ライセンス

本書の本文・原稿は、MIT License で公開されています。

Copyright (c) 2026 FjordBootCamp