ゆっくりしていってね!
Chapter 1: Hotwireとは何か
はじめに
この章では、Hotwireの全体像をつかみます。
Hotwireは、Railsアプリケーションに「SPAっぽい快適さ」を持ち込みつつ、JavaScript中心の複雑なフロントエンド構成を避けるためのアプローチです。
ただし、Hotwireを単なる「便利なライブラリ」として捉えると本質を見失います。大切なのは、Hotwireがアプリケーションの責務分担をどう変えるか、そしてHTMLを中心に据えたままモダンなUXを実現する思想にあります。
この章では、次の4つを学びます。
- 従来のRails開発とSPA開発にはどんな課題があるのか
- Hotwireの思想である HTML over the wire とは何か
- Turbo と Stimulus はそれぞれ何を担当するのか
- Hotwireが向いているケースと、あまり向いていないケースは何か
なお、この章では実装を最小限にとどめ、まずは考え方に慣れることを重視します。
1.1 従来のRailsとSPAの課題
会話でつかむ導入
ゆっくり霊夢 「魔理沙、Railsって昔から“すぐ作れて便利”って言われてるのに、なんで最近はReactとかVueとかをわざわざ組み合わせることが多くなったの?」
ゆっくり魔理沙 「そこがこの章の最初のポイントだぜ。昔ながらのRailsは、サーバーでHTMLを作って、画面遷移のたびにページ全体を再読み込みするのが基本だったんだ。」
ゆっくり霊夢 「それってシンプルで良さそうだけど、何が不満だったの?」
ゆっくり魔理沙 「ユーザー体験だな。たとえば一覧の一部だけ更新したいとか、モーダルを出したいとか、入力中に即時反応したいとか、そういう“ぬるっと動くUI”を作るのが苦手だったんだぜ。」
ゆっくり霊夢 「そこでSPAが出てきたのね。」
ゆっくり魔理沙 「そう。フロントエンドをJavaScriptでがっつり作ることで、画面遷移や部分更新を高速にして、リッチな体験を実現した。でも今度は別の問題が増えたんだ。」
従来のRailsのよさとつらさ
まずは、従来のRailsアプリケーションの典型的な流れを見てみましょう。
# config/routes.rb Rails.application.routes.draw do resources :posts end
# app/controllers/posts_controller.rb class PostsController < ApplicationController def index @posts = Post.order(created_at: :desc) end def show @post = Post.find(params[:id]) end end
<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>
<ul>
<% @posts.each do |post| %>
<li>
<%= link_to post.title, post_path(post) %>
</li>
<% end %>
</ul>
この構成には大きな利点があります。
- サーバー側で責務がまとまりやすい - ルーティング、コントローラ、ビューの流れが自然 - SEOに強い - 初期表示がわかりやすい - フォーム送信やバリデーションが標準でまとまっている
一方で、次のような要望が出ると工夫が必要になります。
- 一覧の一部だけ差し替えたい - モーダルで新規作成フォームを開きたい - 保存後にページ全体を再読み込みしたくない - 他のユーザーの更新をリアルタイムで反映したい - ボタンを押した瞬間にUIの状態を切り替えたい
従来のRailsでもjQueryやvanilla JSで対応はできますが、画面ごとにJavaScriptが増え、保守が難しくなりがちです。
SPAのよさとつらさ
次に、SPA的な構成をざっくり見てみます。
# config/routes.rb Rails.application.routes.draw do namespace :api do resources :posts end end
# app/controllers/api/posts_controller.rb class Api::PostsController < ApplicationController def index posts = Post.order(created_at: :desc) render json: posts end end
// Reactの雰囲気だけを示す簡単な例 import { useEffect, useState } from "react"; export default function Posts() { const [posts, setPosts] = useState([]); useEffect(() => { fetch("/api/posts") .then((response) => response.json()) .then((data) => setPosts(data)); }, []); return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }
SPAにはたしかに強みがあります。
- 部分更新が得意 - 画面遷移が滑らか - 複雑なインタラクションを作りやすい - UIの状態管理をコードで明示しやすい
しかし、Rails開発者にとっては次のような負担も出てきます。
- フロントエンドとバックエンドが分離しやすい - JSON API設計が必要になる - 認証・認可の境界が増える - バリデーションやエラー表示の経路が複雑になる - SSRやSEOを考えると追加設計が必要 - 同じデータ構造を複数箇所で管理しがち
会話で整理
ゆっくり霊夢 「なるほど。RailsはシンプルだけどUIが物足りなくなりやすい。SPAはリッチだけど構成が重くなりがち、ってことね。」
ゆっくり魔理沙 「その通りだぜ。そこで“HTMLを捨てずに、もっとモダンなUIを作れないか?”という発想が出てきた。それがHotwireなんだ。」
比較表
+----------------------+------------------------+---------------------------+ | 観点 | 従来のRails | SPA | +----------------------+------------------------+---------------------------+ | 描画の中心 | サーバーでHTML生成 | クライアントで描画 | | 通信 | HTML | JSON / API | | 部分更新 | 苦手 | 得意 | | 初期構築 | シンプル | やや複雑 | | SEO | 強い | 工夫が必要 | | 状態管理 | 比較的少ない | 複雑になりやすい | | 実装責務 | Railsに寄せやすい | フロント/バック分離しやすい| +----------------------+------------------------+---------------------------+
1.2 Hotwireの思想(HTML over the wire)
会話で導入
ゆっくり霊夢 「で、そのHotwireは何をするの?」
ゆっくり魔理沙 「ひとことで言うと、“JSONじゃなくてHTMLを送ろうぜ”という発想だな。」
ゆっくり霊夢 「えっ、逆行してない?」
ゆっくり魔理沙 「そう見えるけど、むしろRailsとは相性抜群なんだ。サーバー側でHTMLをちゃんと組み立てられるなら、そのHTML断片を送って画面の一部だけ差し替えればいい。わざわざJSONを返して、フロントで組み立て直さなくてもいいんだぜ。」
HTML over the wire とは
従来のSPAでは、サーバーはデータをJSONで返し、ブラウザ側でHTMLを組み立てます。
[ { "id": 1, "title": "Learn Hotwire" }, { "id": 2, "title": "Build a Rails app" } ]
これに対して Hotwire では、サーバーが最初からHTMLを返す方向に寄せます。
<li id="post_1">Learn Hotwire</li> <li id="post_2">Build a Rails app</li>
この考え方が HTML over the wire です。
つまり、
サーバーはデータだけでなく、表示に必要なHTMLも返す ↓ ブラウザはそのHTMLを受け取って差し込む ↓ 結果として、部分更新や高速な画面遷移を実現する
という流れです。
JSONを返す場合との違い
たとえば投稿作成後に一覧へ新しい行を追加したいとします。
JSON中心の発想
render json: { id: @post.id, title: @post.title }
fetch("/posts", { method: "POST", body: formData }).then(async (response) => { const post = await response.json(); const li = document.createElement("li"); li.textContent = post.title; document.querySelector("#posts").appendChild(li); });
HTML over the wire の発想
<!-- app/views/posts/_post.html.erb --> <li id="<%= dom_id(post) %>"> <%= post.title %> </li>
<!-- app/views/posts/create.turbo_stream.erb -->
<%= turbo_stream.append "posts", partial: "posts/post", locals: { post: @post } %>
ここでは、クライアント側でDOM構築の詳細を書いていません。 どう表示するかはRailsのビュー側に集約されています。
なぜこれがうれしいのか
Hotwireの思想が便利なのは、表示ロジックをサーバー側に戻せるからです。
- HTMLの組み立てをERBやViewComponentに寄せられる - API専用の画面組み立てコードを減らせる - サーバー側のテンプレート資産をそのまま活かせる - フロントエンドの状態管理を軽くしやすい
とくにRailsアプリでは、モデル、コントローラ、ビューの流れがそのまま生きます。
注意点
もちろん、HTML over the wire が万能というわけではありません。
- 非常に複雑なクライアント状態管理には向かない - オフライン前提のアプリには不向き - ブラウザ上で巨大なインタラクティブUIを組むなら限界がある
それでも、業務アプリや管理画面、フォーム主体のアプリではかなり強力です。
会話で整理
ゆっくり霊夢 「つまりHotwireは、“フロントエンドでもっと頑張る”じゃなくて、“サーバーで作ったHTMLをうまく届ける”って考え方なのね。」
ゆっくり魔理沙 「そうだぜ。Railsがもともと得意だった領域を活かしながら、今っぽいUXを手に入れる。そこがHotwireのうまさなんだ。」
1.3 Turbo / Stimulusの役割分担
会話で導入
ゆっくり霊夢 「Hotwireってひとつのライブラリじゃないの?」
ゆっくり魔理沙 「実際には、主役は主に2つだぜ。Turbo と Stimulus だ。」
ゆっくり霊夢 「名前は聞くけど、いつも役割がごっちゃになるのよね。」
ゆっくり魔理沙 「そこをここで切り分けるんだ。ざっくり言うと、Turboは“通信と画面更新の仕組み”、Stimulusは“ちょい足しのJavaScript制御”だぜ。」
Turboの役割
Turboは、大きく次のようなことを担当します。
- ページ遷移を高速化する - フォーム送信後の遷移や更新を自然にする - 画面の一部だけを差し替える - サーバーからのHTML更新をDOMへ反映する
Turboがやってくれることのイメージはこんな感じです。
<a href="/posts/1">Show</a>
普通のリンクに見えても、Turboが有効なら裏側では高速化された遷移になります。
また、フォーム送信もTurbo対応になります。
<%= form_with model: @post do |f| %> <%= f.text_field :title %> <%= f.submit "Save" %> <% end %>
さらに、一部分だけを囲って更新することもできます。
<%= turbo_frame_tag "new_post" do %> <%= render "form", post: @post %> <% end %>
Turboは、HTMLの受け渡しと反映の自動化を担当していると考えるとわかりやすいです。
Stimulusの役割
Stimulusは、HTMLに少しだけJavaScriptのふるまいを足すための仕組みです。
たとえば、ボタンを押したら詳細を開閉するようなUIを考えます。
<div data-controller="toggle"> <button data-action="click->toggle#toggle">Toggle</button> <div data-toggle-target="content" hidden> Hidden content </div> </div>
// app/javascript/controllers/toggle_controller.js import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["content"]; toggle() { this.contentTarget.hidden = !this.contentTarget.hidden; } }
Stimulusは、Reactのように画面全体を支配するというより、HTMLに行動を結びつける小さなコントローラです。
- 開閉 - タブ切り替え - 入力補助 - 文字数カウント - コピー操作 - 確認ダイアログの拡張
このような「軽い振る舞い」をきれいに分離できます。
TurboとStimulusの違いをひとことで
Turbo = サーバーとHTMLの往復を賢くする Stimulus = ブラウザ上の細かなふるまいを足す
役割分担の具体例
例1: 投稿一覧に新規投稿を追加する
- 投稿作成フォーム送信
- サーバーが投稿を保存
- Turbo Streamで一覧末尾にHTMLを追加
この場合、主役は Turbo です。
<%= turbo_stream.append "posts", partial: "posts/post", locals: { post: @post } %>
例2: 入力文字数をリアルタイム表示する
- テキストエリア入力
- 入力文字数をその場で表示
- サーバー通信は不要
この場合、主役は Stimulus です。
<div data-controller="counter"> <textarea data-action="input->counter#update" data-counter-target="input"></textarea> <p><span data-counter-target="output">0</span> characters</p> </div>
// app/javascript/controllers/counter_controller.js import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["input", "output"]; update() { this.outputTarget.textContent = this.inputTarget.value.length; } }
会話で整理
ゆっくり霊夢 「Turboはサーバーとのやり取り寄り、Stimulusはブラウザ内の小さい動き寄り、って感じね。」
ゆっくり魔理沙 「その理解でかなりいいぜ。Hotwireでは“まずTurboで解決できないか考える”。それでも足りない細かいUIをStimulusで補う、という順番が基本なんだ。」
ゆっくり霊夢 「最初から全部JavaScriptで作ろうとしないのがコツなのね。」
ゆっくり魔理沙 「そうだぜ。そこを間違えると、Hotwireなのに結局フロントが重くなる。」
1.4 どんなケースで使うべきか/使わないべきか
会話で導入
ゆっくり霊夢 「ここまで聞くと、Hotwireってかなり万能に見えるわ。」
ゆっくり魔理沙 「便利なのは確かだけど、向き不向きはちゃんとあるぜ。ここを見誤ると、後でつらくなる。」
Hotwireが向いているケース
1. フォーム中心の業務アプリ
たとえば次のようなものです。
- 管理画面 - 社内ツール - タスク管理アプリ - CMS - 予約システム - ECの運用画面
こうしたアプリでは、Railsのフォーム、バリデーション、部分テンプレートがとても活きます。
<%= form_with model: @task do |f| %>
<% if @task.errors.any? %>
<ul>
<% @task.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
このようなRailsの得意技をそのまま使いながら、UXを改善しやすいのがHotwireの強みです。
2. 一部更新が多い画面
- 一覧の一行だけ更新 - モーダルの中身だけ差し替え - タブの中身を非同期読み込み - コメント一覧へ新着を追加
Turbo Frames / Turbo Streams が非常に相性のよい領域です。
<%= turbo_frame_tag dom_id(task) do %> <%= render task %> <% end %>
3. サーバー側に表示ロジックを集めたい場合
ERB、partial、helper、ViewComponentなどにすでに資産がある場合、Hotwireはかなり強いです。
- 既存のRailsアプリを大きく壊さず改善できる - JSON APIを全面設計しなくてよい - 表示ルールの重複を減らしやすい
Hotwireがあまり向いていないケース
1. クライアント側の状態が極端に複雑なUI
たとえば次のようなものです。
- 高機能なデザインツール - 複雑なドラッグ&ドロップエディタ - 大規模な表計算UI - オフライン中心のアプリ - ブラウザ内だけで状態が大量に変化する画面
このような領域では、クライアント状態管理が主役になります。 その場合はReactやVueのほうが自然なことが多いです。
2. フロントエンドを完全分離したい場合
たとえば、
- Web - iOS - Android - 外部公開API
を共通バックエンドで支えたいなら、JSON APIやGraphQLを中心に設計する価値があります。 HotwireはHTMLを返す発想なので、マルチクライアント共通API中心設計とは少し方向が違います。
3. フロントエンド専門チームが大きい場合
組織としてフロントエンドとバックエンドを明確に分離しているなら、Hotwireのメリットが薄まる場合があります。
Hotwireは、サーバー側とビューを近くに置きたいチームにとくに向いています。
判断のための簡易チェックリスト
次の項目に多く当てはまるなら、Hotwireはかなり有力です。
[ ] Railsのテンプレート資産を活かしたい [ ] 管理画面や業務画面が中心 [ ] フォームやCRUDが多い [ ] SPAほどの複雑な状態管理は不要 [ ] できるだけJavaScriptを減らしたい [ ] 開発速度と保守性を重視したい
逆に、次の項目が多いなら慎重に考えるべきです。
[ ] ブラウザだけで大量の状態を持つ [ ] UI部品が非常に複雑 [ ] オフライン動作が重要 [ ] モバイルアプリと完全共通APIを前提にしたい [ ] フロントエンド主導の設計が必要
会話で整理
ゆっくり霊夢 「要するに、Hotwireは“普通のWebアプリを、気持ちよくモダン化する”のが得意なのね。」
ゆっくり魔理沙 「そうだぜ。特にRailsのCRUDやフォームやサーバーレンダリングと仲がいい。逆に、超クライアント主役の世界では無理に使わないほうがいいこともある。」
ゆっくり霊夢 「なんでもHotwireでやる、でもないのね。」
ゆっくり魔理沙 「技術選定は宗教じゃなくて適材適所だからな。」
この章のまとめ
この章では、Hotwireの背景と考え方を学びました。
- 従来のRailsはシンプルで強力だが、部分更新やリッチなUIには工夫が必要だった
- SPAは高機能なUIに強いが、構成や責務分担が複雑になりやすい
- Hotwireは HTML over the wire という考え方で、HTML中心のままモダンなUXを実現する
- Turbo は通信とHTML更新を担当し、Stimulus は小さなJavaScriptのふるまいを担当する
- Hotwireは、フォーム中心・CRUD中心・業務アプリ中心のRailsプロジェクトと特に相性がよい
次章からは、実際にRailsプロジェクトへHotwireを導入し、TurboやStimulusがどのように動くのかを手を動かしながら確認していきます。
練習問題
問1
従来のRailsアプリケーションで、SPAに比べて苦手になりやすいUIはどのようなものですか。2つ以上挙げてください。
問2
HTML over the wire とはどのような考え方ですか。JSON中心の設計との違いを説明してください。
問3
次の処理は Turbo と Stimulus のどちらが主役になるべきでしょうか。
- フォーム送信後に一覧へ新しい行を追加する
- 入力欄の文字数をリアルタイム表示する
- 一覧の一部だけを非同期で差し替える
問4
あなたが現在作っている、あるいは過去に作ったRailsアプリを1つ思い浮かべてください。 そのアプリはHotwire向きですか。それともSPA向きですか。理由も書いてみましょう。
章末ミニコラム: Hotwireは「Reactの敵」ではない
ゆっくり霊夢 「Hotwireを学ぶってことは、Reactを捨てるってこと?」
ゆっくり魔理沙 「そこも誤解されやすいけど、別にそうじゃないぜ。Hotwireは“Railsにとって自然な選択肢を増やす”ものだ。」
ゆっくり霊夢 「じゃあ共存もできるの?」
ゆっくり魔理沙 「もちろんだぜ。基本はHotwireで作って、一部の複雑なUIだけReactを使う、みたいな設計も普通にある。」
たとえば次のような考え方です。
- 画面全体はRails + Turboで作る - 特定の高度なウィジェットだけReactで作る - 軽いDOM操作はStimulusで書く
重要なのは、技術そのものではなく、どこに複雑さを置くかです。 Hotwireは、その複雑さを必要以上にクライアントへ押し込まないための強力な選択肢です。
Chapter 2: 開発環境セットアップ
はじめに
ゆっくり霊夢 「第1章でHotwireの考え方はわかったけど、結局どうやって始めればいいの?」
ゆっくり魔理沙 「この章ではそこを手で覚えるぜ。Rails 7系でHotwire入りのアプリを作って、TurboとStimulusがちゃんと動くところまで確認するんだ。」
ゆっくり霊夢 「いきなり複雑な画面は作らないのね。」
ゆっくり魔理沙 「最初はそこまでやらなくていい。まずは“環境が正しくできているか”を、最小コードで確かめるのが大事だぜ。」
この章では次の4つを扱います。
- 2.1 Rails 7でHotwire有効プロジェクト作成
- 2.2 importmap vs esbuild vs Vite(比較)
- 2.3 Turbo / Stimulusの初期構成確認
- 2.4 開発効率を上げるTips
2.1 Rails 7でHotwire有効プロジェクト作成
2.1.1 まずは最小構成で始める
ゆっくり霊夢 「最初から esbuild とか Vite とか選ばないとダメ?」
ゆっくり魔理沙 「最初の学習なら、まずはRails標準に近い構成で始めるのが一番わかりやすいぜ。Rails の JavaScript ガイドでも、import map は Rails の既定選択肢として説明されている。」 ([Ruby on Rails Guides][1])
まずは新規アプリを作成します。
rails new hotwire_sandbox cd hotwire_sandbox bin/rails server
Rails の JavaScript まわりは、import map を使う場合は別ビルド工程なしで動かせるのが特徴です。bin/rails server だけで始めやすい、というのは学習用にかなり大きい利点です。 ([Ruby on Rails Guides][1])
2.1.2 Hotwire入りで明示的に作る
環境やテンプレートによっては、最初から明示しておくと安心です。
rails new hotwire_sandbox --javascript=importmap cd hotwire_sandbox bin/rails turbo:install stimulus:install bin/rails server
ゆっくり霊夢
「turbo:install と stimulus:install って必要なの?」
ゆっくり魔理沙 「テンプレート次第では最初から入っていることもあるが、“今からHotwireを有効にする”という意図がコードに出るから、本ではこの書き方の方が読み手に親切だぜ。」
2.1.3 生成される主要ファイルを確認する
最初に見るべきファイルはこれです。
app/javascript/application.js app/javascript/controllers/application.js app/javascript/controllers/index.js config/importmap.rb app/views/layouts/application.html.erb
たとえば app/javascript/application.js はこんな感じになっています。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers"
この2行がかなり重要です。
@hotwired/turbo-railsを読み込むcontrollers経由で Stimulus controller 群を読み込む
Turbo は Rails 連携時に turbo-rails を使うのが基本で、Stimulus は Rails 連携時に app/javascript/controllers 配下の controller を自動ロードする流れになります。 ([Turbo][2])
2.1.4 layout 側の確認
application.html.erb では JavaScript の読み込みが必要です。import map の場合は次のようになります。
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>HotwireSandbox</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
import map では javascript_importmap_tags が import map 定義とモジュール読み込みをまとめて差し込む役割を持ちます。 ([Ruby on Rails Guides][3])
2.1.5 動作確認用のトップページを作る
まずは最小の画面を作ります。
bin/rails generate controller Pages home
# config/routes.rb Rails.application.routes.draw do root "pages#home" end
<!-- app/views/pages/home.html.erb --> <h1>Hello Hotwire</h1> <p>It works!</p>
サーバーを起動します。
bin/rails server
ブラウザでトップページが表示されれば、土台はできています。
2.1.6 ここでの到達点
この節のゴールはまだ地味です。
- Railsアプリを作れた - Hotwire用の基本ファイルを確認した - import map 前提で最小ページを表示できた
ゆっくり霊夢 「まだ“Hotwire感”は薄いわね。」
ゆっくり魔理沙 「そうだぜ。でも最初に環境を曖昧にすると、後で“Turboが効かない”“Stimulusが読み込まれない”で詰まりやすい。まずは土台だ。」
2.2 importmap vs esbuild vs Vite(比較)
2.2.1 まず結論
ゆっくり霊夢 「で、JavaScriptの方式はどれを選べばいいの?」
ゆっくり魔理沙 「学習用ならまず import map、本格運用で npm パッケージやフロント資産が増えるなら esbuild か Vite を検討、って感じだぜ。」
Rails ガイドでは、import map は新規アプリの既定で、Node.js や Yarn なしでも運用でき、別ビルド工程が不要です。一方で bundler を使いたい場合は --javascript オプションで esbuild などを選べます。 ([Ruby on Rails Guides][1])
2.2.2 importmap の特徴
作成例
rails new myapp --javascript=importmap
イメージ
# config/importmap.rb pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin_all_from "app/javascript/controllers", under: "controllers"
特徴
- Rails標準に近い - Node.jsなしでも始めやすい - 別ビルド不要 - 学習コストが低い - 小〜中規模のRailsアプリに向く
弱点
- npmエコシステムをフル活用する構成にはやや不向き - 複雑なフロント資産管理には限界がある - TypeScriptや高度なフロント開発を強く前提にするなら物足りないことがある
ゆっくり霊夢 「“Railsらしさ重視”って感じね。」
ゆっくり魔理沙 「そうだぜ。Hotwireをまず理解したいだけなら、かなり相性がいい。」
2.2.3 esbuild の特徴
作成例
rails new myapp --javascript=esbuild
Rails ガイドでは、bundler を使う場合の選択肢として esbuild などが挙げられています。 ([Ruby on Rails Guides][1])
イメージ
// app/javascript/application.js import "@hotwired/turbo-rails" import "./controllers"
特徴
- importmapよりフロント資産管理がしやすい - npm パッケージ導入が自然 - ビルドが比較的軽い - “Rails + 少しモダンJS” の落としどころとして使いやすい
弱点
- Node.js環境が必要 - 学習用としては importmap より少し重い - ビルド失敗時に見る場所が増える
2.2.4 Vite の特徴
Vite は Rails の公式標準そのものではありませんが、Rails と組み合わせる実務構成としてかなり人気があります。vite_rails / vite_ruby 系の構成は、開発時の高速な HMR などが魅力です。今回は比較対象として扱います。
※ ここでは本筋を Hotwire に置くため、導入は簡潔にします。
導入イメージ
bundle add vite_rails bin/rails vite:install
特徴
- フロント開発体験がかなり良い - HMRが快適 - npm パッケージとの相性が良い - 将来的に React / Vue 併用もしやすい
弱点
- Rails標準からは少し離れる - 初学者には構成把握がやや難しい - “Hotwireだけ学びたい” 段階ではオーバースペックになりやすい
2.2.5 比較表
+------------+----------------------+----------------------+----------------------+ | 項目 | importmap | esbuild | Vite | +------------+----------------------+----------------------+----------------------+ | 導入の軽さ | とても軽い | 軽い | やや重い | | Node必要 | 不要 | 必要 | 必要 | | 学習向き | とても向く | 向く | 少し中上級向け | | npm相性 | やや制限あり | 良い | とても良い | | Rails標準感| 強い | 中くらい | 弱め | | Hotwire学習| 最適 | 良い | 悪くないが過剰な時も | +------------+----------------------+----------------------+----------------------+
2.2.6 本書での推奨方針
本書では、まず次の方針にします。
- 本文の基本ハンズオンは importmap で進める - 各章の補足で esbuild / Vite の読み替えを少し入れる - Hotwireの本質理解を最優先にする
ゆっくり霊夢 「たしかに、最初から道具を増やしすぎるとHotwireの話がぼやけるわね。」
ゆっくり魔理沙 「そうなんだぜ。目的は“ビルドツール習得”じゃなくて、“TurboとStimulusを使いこなすこと”だからな。」
2.3 Turbo / Stimulusの初期構成確認
2.3.1 Turbo が読み込まれているか確認する
Turbo はリンククリックやフォーム送信をフックして、バックグラウンド通信でページ更新を高速化します。Hotwire の公式 Turbo Handbook でも、Turbo Drive はリンククリックとフォーム送信を監視し、フルリロードなしの更新を行うと説明されています。 ([Turbo][4])
まずは2ページ作って、Turbo Drive の土台が効いていることを確認します。
bin/rails generate controller Pages home about
# config/routes.rb Rails.application.routes.draw do root "pages#home" get "about", to: "pages#about" end
<!-- app/views/pages/home.html.erb --> <h1>Home</h1> <p><%= link_to "Aboutへ", about_path %></p>
<!-- app/views/pages/about.html.erb --> <h1>About</h1> <p><%= link_to "Homeへ", root_path %></p>
application.js を確認します。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers"
これが入っていれば、基本的には Turbo Drive が有効です。
2.3.2 Turbo をイベントで確認する
本では、「見えない仕組みを見える化する」のが大事です。 Turbo のイベントをログ出力してみましょう。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers" document.addEventListener("turbo:load", () => { console.log("Turbo loaded") }) document.addEventListener("turbo:visit", (event) => { console.log("Turbo visit:", event.detail.url) })
ブラウザの開発者ツールを開き、リンクをクリックしてください。
Turbo loaded Turbo visit: http://localhost:3000/about Turbo loaded
のようなログが出れば、Turbo 経由のページ遷移が起きています。
ゆっくり霊夢 「おお、ちゃんと動いてる感じがする。」
ゆっくり魔理沙 「こういう確認を飛ばさないのが大事だぜ。“たぶん動いてる” を減らすんだ。」
2.3.3 Stimulus controller を作ってみる
次に Stimulus が正しく動いているか確認します。Stimulus の公式 Handbook では、Rails 連携時に app/javascript/controllers 配下の [identifier]_controller.js が自動ロードされ、ファイル名のアンダースコアは HTML 側の identifier ではダッシュに対応すると説明されています。 ([Stimulus][5])
まず controller を作成します。
// app/javascript/controllers/hello_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["output"] connect() { console.log("HelloController connected") this.outputTarget.textContent = "Stimulus is working!" } }
controllers/index.js は通常こんな形です。
// app/javascript/controllers/index.js import { application } from "controllers/application" import HelloController from "./hello_controller" application.register("hello", HelloController)
表示側です。
<!-- app/views/pages/home.html.erb --> <h1>Home</h1> <div data-controller="hello"> <p data-hello-target="output">Waiting...</p> </div> <p><%= link_to "Aboutへ", about_path %></p>
ページを開くと、Waiting... が Stimulus is working! に置き換わります。
2.3.4 クリックイベントも確認する
今度はボタン操作を追加します。
// app/javascript/controllers/hello_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["output"] connect() { this.outputTarget.textContent = "Stimulus is ready!" } greet() { this.outputTarget.textContent = "Hello from Stimulus!" } }
<!-- app/views/pages/home.html.erb -->
<h1>Home</h1>
<div data-controller="hello">
<p data-hello-target="output">Waiting...</p>
<button data-action="click->hello#greet">
あいさつする
</button>
</div>
ここで大事なのは次の対応です。
hello_controller.js → data-controller="hello" output target → data-hello-target="output" greet メソッド → click->hello#greet
Stimulus はこの命名規約がかなり重要です。
2.3.5 Turbo と Stimulus が同時に動いていることを確認する
ここまでで、
- ページ遷移は Turbo
- 画面内の小さなふるまいは Stimulus
という役割分担が確認できました。
整理するとこうです。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers"
<!-- app/views/pages/home.html.erb --> <div data-controller="hello"> <p data-hello-target="output">Waiting...</p> <button data-action="click->hello#greet">あいさつする</button> </div> <%= link_to "Aboutへ", about_path %>
ゆっくり霊夢 「たしかに、JavaScriptを書いてるのに“全部をJSで作ってる感”がないわね。」
ゆっくり魔理沙 「そこがHotwireの良さだぜ。HTMLが中心のまま、必要なところだけ動きを足していくんだ。」
2.3.6 importmap でパッケージを足すとき
Rails ガイドでは、import map で外部パッケージを追加するには bin/importmap pin を使う流れが示されています。 ([Ruby on Rails Guides][1])
たとえば何かパッケージを追加する場合はこうです。
bin/importmap pin lodash
すると config/importmap.rb に pin が追加されます。
pin "lodash"
JavaScript 側ではこう使えます。
import _ from "lodash"
本書の前半では、できるだけ外部依存を増やさず、Turbo と Stimulus の理解を優先します。
2.4 開発効率を上げるTips
2.4.1 最初に見るファイルを固定する
ゆっくり霊夢 「Railsってファイルが多くて、どこを見ればいいか迷うのよね。」
ゆっくり魔理沙 「Hotwire学習の序盤は、見る場所を固定するとかなり楽だぜ。」
最初に頻繁に触るのは次の5つです。
config/routes.rb app/controllers/* app/views/* app/javascript/application.js app/javascript/controllers/*
この範囲だけでかなりのことができます。
2.4.2 “動いたかどうか”をログで確認する
Turbo も Stimulus も、最初は「静かに失敗」しやすいです。 なので、学習中は積極的にログを入れます。
document.addEventListener("turbo:load", () => { console.log("turbo:load fired") })
// Stimulus controller connect() { console.log("connected") }
# controller def home Rails.logger.info "PagesController#home called" end
<!-- view --> <p>Rendered at: <%= Time.current %></p>
こういう泥くさい確認が、いちばん早いです。
2.4.3 学習中は1画面1目的にする
悪い例です。
- Turbo Drive を試したい - ついでに Stimulus も足す - ついでに Tailwind も入れる - ついでに モーダルも作る
これだと、どこで壊れたかわからなくなります。
良い例です。
Step 1: Turbo Drive だけ確認 Step 2: Stimulus connect だけ確認 Step 3: click action だけ確認 Step 4: Turbo Frame を試す
ゆっくり霊夢 「一気に盛りすぎると、原因の切り分けができないのね。」
ゆっくり魔理沙 「そうだぜ。ハンズオン本では特に、“一章ごとに理解ポイントを絞る”のが大事なんだ。」
2.4.4 Turbo を一時的に切る方法を知っておく
トラブル時には、Turbo の影響を切り分けると早いことがあります。
リンク単位で無効化する例です。
<%= link_to "通常遷移", about_path, data: { turbo: false } %>
フォーム単位でも同様です。
<%= form_with url: "/search", data: { turbo: false } do |f| %>
<%= f.text_field :keyword %>
<%= f.submit "Search" %>
<% end %>
Turbo Drive は通常のリンククリックやフォーム送信を強化する仕組みなので、問題の切り分けとして data-turbo="false" を知っておくのは役立ちます。 ([Turbo][4])
2.4.5 Turbo でフルリロードが必要なページを知る
Hotwire の公式では、特定ページでフルリロードを強制したい場合、turbo-visit-control を reload にする方法が案内されています。Rails では helper も使えます。 ([Turbo][6])
たとえばログイン画面や、特殊な初期化が必要なページでは次のようにできます。
<%# app/views/layouts/application.html.erb など %> <meta name="turbo-visit-control" content="reload">
または Rails helper を使います。
<%= turbo_page_requires_reload %>
ゆっくり霊夢 「“Turboが全部正義”じゃなくて、必要ならフルリロードに戻せるのね。」
ゆっくり魔理沙 「そうだぜ。Hotwireは柔らかく使うのがコツだ。」
2.4.6 Stimulus controller は小さく保つ
よくない例です。
// 何でも1つのcontrollerに詰め込む export default class extends Controller { connect() {} openModal() {} closeModal() {} search() {} sort() {} copy() {} preview() {} validate() {} }
おすすめは、責務ごとに小さく分けることです。
// modal_controller.js export default class extends Controller { open() {} close() {} }
// counter_controller.js export default class extends Controller { update() {} }
// clipboard_controller.js export default class extends Controller { copy() {} }
Stimulus は「画面全体を支配する大きな部品」ではなく、「HTMLにちょい足しする小さな行動単位」として使うときれいに保守しやすいです。これは Stimulus の設計思想ともかなり一致しています。 ([Stimulus][5])
2.4.7 最低限の開発メモを残す
本で勧めるなら、プロジェクト直下に小さなメモを置くのも有効です。
# notes/hotwire-checklist.md - application.js で turbo-rails を import したか - application.js で controllers を import したか - layout に javascript_importmap_tags があるか - Stimulus controller 名と data-controller 名は一致しているか - target 名と action 名は一致しているか - Turbo を切ると症状が変わるか
初心者ほど、こういうチェックリストが効きます。
この章のまとめ
ゆっくり霊夢 「だいぶ見えてきたわ。最初は importmap で軽く始めて、Turbo と Stimulus が動く最小構成をちゃんと確かめるのが大事なのね。」
ゆっくり魔理沙 「その通りだぜ。この章のポイントをまとめるとこうだ。」
- Rails 7系では import map が標準に近く、Hotwire学習に向いている - importmap はビルド不要で始めやすい - esbuild は npm 利用が自然で、実務の中間解として使いやすい - Vite は開発体験が良いが、学習序盤にはやや重い - Turbo はページ遷移やフォーム送信を高速化する - Stimulus は小さなJavaScriptのふるまいを足す - 最初はログを多めに出して、仕組みを目で確認すると詰まりにくい
練習問題
問1
importmap を使う構成の利点を2つ挙げてください。
問2
次の2つは何を担当していますか。
import "@hotwired/turbo-rails" import "controllers"
問3
Stimulus controller hello_controller.js に対応する data-controller の値は何ですか。
問4
次のボタンをクリックしたときに greet を呼び出すには、? に何を入れればよいですか。
<button data-action="click->hello#?">あいさつ</button>
問5
Turbo の影響かどうか切り分けるために、一時的に通常リンクとして動かしたい場合はどう書きますか。
章末ミニコラム: 本書ではなぜ importmap を基本にするのか
ゆっくり霊夢 「でも実務だとViteのほうが今っぽい気もするわ。」
ゆっくり魔理沙 「それはわりと正しい。でも“Hotwireを学ぶ本”としては、最初からビルドツールの複雑さを背負わせないほうがいいんだ。」
本書で importmap を基本にする理由は次の通りです。
- Hotwireの理解に集中しやすい - Rails標準に近い構成で説明できる - “なぜ動くのか” を追いやすい - 余計なビルドエラーに引っ張られにくい
ただし実務では、次のようなときは esbuild や Vite も十分有力です。
- npm パッケージを多く使う - TypeScript を本格利用したい - React / Vue の一部併用を見込んでいる - フロント開発体験を重視したい
ゆっくり魔理沙 「要するに、“最初は importmap で学ぶ、必要なら後で広げる” でいいんだぜ。」
Chapter 3: ベースアプリの作成(CRUD)
はじめに
ゆっくり霊夢 「第2章で環境はできたけど、まだHotwireっぽいことはそんなにしてないわよね。」
ゆっくり魔理沙 「そうだぜ。でもHotwireに入る前に、まずは普通のRailsのCRUDをしっかり土台にする必要があるんだ。」
ゆっくり霊夢 「え、いきなりTurbo FrameとかTurbo Streamじゃだめなの?」
ゆっくり魔理沙 「だめってほどじゃないけど、HotwireはRailsの上に乗るものだからな。 まず“素のRailsの流れ”を理解しておかないと、Hotwireが何を省力化しているのか見えにくいんだぜ。」
この章では、後の章でHotwire化していくためのベースとして、タスク管理アプリの基本CRUDを作ります。
この章でやることは次の4つです。
- 3.1 タスク管理アプリの設計
- 3.2 scaffoldでCRUD作成
- 3.3 RESTとHTMLレスポンスの基本
- 3.4 レイアウトとパーシャル整理
3.1 タスク管理アプリの設計
3.1.1 今回作るアプリの全体像
ゆっくり霊夢 「どんなアプリを作るの?」
ゆっくり魔理沙 「シンプルなタスク管理アプリだぜ。 でも“シンプル”っていうのが大事なんだ。学習用では、機能を盛りすぎないほうがHotwireのポイントが見えやすい。」
今回作るものは、次のようなタスク管理アプリです。
- タスク一覧を表示する - タスクを新規作成する - タスクの詳細を表示する - タスクを編集する - タスクを削除する
データ項目はまず最小に絞ります。
- title : タスク名 - description : 詳細 - status : 状態 - due_on : 期限日
3.1.2 モデルを先に考える
Railsでは、まず「何を扱うか」をモデルとして考えると整理しやすいです。
今回は Task モデルを用意します。
イメージはこんな感じです。
class Task < ApplicationRecord end
最初は複雑な関連は入れません。 まずは1モデルでCRUDを完成させることが目的です。
ゆっくり霊夢 「最初からユーザー管理とか、プロジェクトごとの所属とかは入れないのね。」
ゆっくり魔理沙 「そうだぜ。最初から多対多とか認証まで入れると、学ぶポイントが分散する。 今は“RailsのCRUDとビュー構成”を掴むのが優先だ。」
3.1.3 画面一覧を先に決める
作る前に、最低限どんな画面が必要かを洗い出しておきます。
GET /tasks 一覧画面 GET /tasks/:id 詳細画面 GET /tasks/new 新規作成画面 POST /tasks 作成処理 GET /tasks/:id/edit 編集画面 PATCH /tasks/:id 更新処理 DELETE /tasks/:id 削除処理
これがRailsの標準的なCRUDルートです。
対応する画面イメージはこうです。
- index : タスク一覧 - show : タスク詳細 - new : 新規作成フォーム - edit : 編集フォーム
ゆっくり霊夢 「これだけでもう“Railsっぽい”わね。」
ゆっくり魔理沙 「そうだぜ。 この規則性があるから、Railsは学びやすいし、Hotwireとも相性がいいんだ。」
3.1.4 最初に作る状態を決める
今回は status を文字列で持たせます。
値はとりあえず次の3つにします。
todo doing done
最初はenumを使わず、まずはシンプルに文字列でも構いません。 ただ、本では後からenumへ育てる流れを見せてもよいでしょう。
まずは頭の中でこう決めます。
# 例として想定するTask Task.new( title: "Learn Hotwire", description: "Read chapter 3 and build CRUD", status: "todo", due_on: Date.today + 7 )
3.1.5 画面遷移を先にイメージする
コードを書く前に、ユーザーの動線を簡単に整理します。
一覧画面 ↓ 「New Task」 新規作成画面 ↓ 「Create Task」 詳細画面 ↓ 「Back to tasks」 一覧画面
編集の流れも同様です。
詳細画面 ↓ 「Edit this task」 編集画面 ↓ 「Update Task」 詳細画面
削除はこうです。
詳細画面 ↓ 「Delete」 一覧画面
このように、どの操作の後にどこへ戻るかを先に決めておくと、コントローラ実装がぶれにくくなります。
3.1.6 この節のまとめ
この時点で決まったことを整理すると、こうです。
モデル: - Task 属性: - title - description - status - due_on 画面: - index - show - new - edit 操作: - create - update - destroy
ゆっくり霊夢 「設計っていうと大げさに聞こえるけど、“まず何を作るか整理する”だけでもかなり違うのね。」
ゆっくり魔理沙 「そうだぜ。 Railsは手が早く動くぶん、設計を飛ばして書き始めると後で散らかりやすいんだ。」
3.2 scaffoldでCRUD作成
3.2.1 scaffoldを使う理由
ゆっくり霊夢 「じゃあいよいよ作るのね。今回は scaffold でいくの?」
ゆっくり魔理沙 「そうだぜ。 学習用としては、まず scaffold でひと通り揃えたほうが全体像を掴みやすい。」
scaffold は、次のものを一気に生成してくれます。
- migration - model - controller - views - routes - tests
まずは次のコマンドを実行します。
bin/rails generate scaffold Task title:string description:text status:string due_on:date
そのあとマイグレーションを流します。
bin/rails db:migrate
サーバーが起動していなければ起動します。
bin/rails server
3.2.2 生成結果を確認する
生成後、いろいろなファイルが作られます。 特に重要なのは次のあたりです。
app/models/task.rb app/controllers/tasks_controller.rb app/views/tasks/index.html.erb app/views/tasks/show.html.erb app/views/tasks/new.html.erb app/views/tasks/edit.html.erb app/views/tasks/_form.html.erb config/routes.rb db/migrate/XXXXXXXXXXXXXX_create_tasks.rb
config/routes.rb を見ると、こんなコードが追加されています。
Rails.application.routes.draw do resources :tasks end
ゆっくり霊夢 「たった1行でCRUD全部?」
ゆっくり魔理沙
「そうだぜ。
Railsの resources はかなり強力だ。」
3.2.3 migrationを読む
生成されたmigrationも確認しておきます。
# db/migrate/xxxxxxxxxxxxxx_create_tasks.rb class CreateTasks < ActiveRecord::Migration[7.0] def change create_table :tasks do |t| t.string :title t.text :description t.string :status t.date :due_on t.timestamps end end end
この定義から、tasks テーブルに必要なカラムが作られます。
id title description status due_on created_at updated_at
timestamps は Rails ではかなり頻出です。
3.2.4 modelを読む
生成されたモデルは最初こんな感じです。
# app/models/task.rb class Task < ApplicationRecord end
まずは最低限のバリデーションを追加してみましょう。
# app/models/task.rb class Task < ApplicationRecord validates :title, presence: true validates :status, presence: true end
これで、タイトルも状態も空では保存できなくなります。
試しに Rails console で確認してみます。
bin/rails console
task = Task.new task.valid? # => false task.errors.full_messages # => ["Title can't be blank", "Status can't be blank"]
ゆっくり霊夢 「こういう小さい確認、大事ね。」
ゆっくり魔理沙 「大事だぜ。 ブラウザだけで確認してると、何が起きてるか見えにくいことがあるからな。」
3.2.5 controllerを読む
scaffoldで生成されたコントローラは少し長いですが、Railsの基本が全部入っています。
# app/controllers/tasks_controller.rb class TasksController < ApplicationController before_action :set_task, only: %i[ show edit update destroy ] def index @tasks = Task.all end def show end def new @task = Task.new end def edit end def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end def update if @task.update(task_params) redirect_to @task, notice: "Task was successfully updated." else render :edit, status: :unprocessable_entity end end def destroy @task.destroy redirect_to tasks_url, notice: "Task was successfully destroyed." end private def set_task @task = Task.find(params[:id]) end def task_params params.require(:task).permit(:title, :description, :status, :due_on) end end
ここで重要なのは次の3点です。
- アクションごとに役割が分かれている - 成功時と失敗時の分岐がある - strong parameters で受け取る項目を制限している
3.2.6 一覧画面を見てみる
まずは index ビューを見ます。
<!-- app/views/tasks/index.html.erb -->
<p style="color: green"><%= notice %></p>
<h1>Tasks</h1>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render task %>
<p>
<%= link_to "Show this task", task %>
</p>
<% end %>
</div>
<%= link_to "New task", new_task_path %>
ゆっくり霊夢
「あれ、render task ってなんだか省略されてる感じね。」
ゆっくり魔理沙
「そうだぜ。
これは task に対応するパーシャル、つまり _task.html.erb を自動で探して描画してくれる書き方なんだ。」
対応するパーシャルはこうです。
<!-- app/views/tasks/_task.html.erb -->
<div id="<%= dom_id task %>">
<p>
<strong>Title:</strong>
<%= task.title %>
</p>
<p>
<strong>Description:</strong>
<%= task.description %>
</p>
<p>
<strong>Status:</strong>
<%= task.status %>
</p>
<p>
<strong>Due on:</strong>
<%= task.due_on %>
</p>
</div>
この dom_id task は後のHotwire章でもかなり重要になります。
3.2.7 実際にデータを入れてみる
ブラウザで /tasks にアクセスし、いくつかデータを作成してみましょう。
例:
Title: Learn Hotwire Description: Finish chapter 3 Status: todo Due on: 2026-04-10
Title: Build Turbo Frame example Description: Prepare chapter 5 sample Status: doing Due on: 2026-04-12
こうしてデータが一覧に表示されれば、ベースとなるCRUDは完成です。
3.3 RESTとHTMLレスポンスの基本
3.3.1 RESTとは何か
ゆっくり霊夢 「RailsってよくRESTfulって言うけど、結局なんなの?」
ゆっくり魔理沙 「ざっくり言えば、URLとHTTPメソッドに意味を持たせて、操作を整理する考え方だぜ。」
タスクに対する操作を表にするとこうなります。
+-----------+-------------+----------------------+----------------+ | 操作 | HTTPメソッド | パス | アクション | +-----------+-------------+----------------------+----------------+ | 一覧 | GET | /tasks | index | | 詳細 | GET | /tasks/:id | show | | 新規画面 | GET | /tasks/new | new | | 作成 | POST | /tasks | create | | 編集画面 | GET | /tasks/:id/edit | edit | | 更新 | PATCH/PUT | /tasks/:id | update | | 削除 | DELETE | /tasks/:id | destroy | +-----------+-------------+----------------------+----------------+
この規則があるので、Railsでは画面と処理の流れを理解しやすいのです。
3.3.2 routesで確認する
ルーティングを確認したいときは、次のコマンドが便利です。
bin/rails routes -g task
すると、たとえば次のような一覧が出ます。
tasks GET /tasks(.:format) tasks#index
POST /tasks(.:format) tasks#create
new_task GET /tasks/new(.:format) tasks#new
edit_task GET /tasks/:id/edit(.:format) tasks#edit
task GET /tasks/:id(.:format) tasks#show
PATCH /tasks/:id(.:format) tasks#update
PUT /tasks/:id(.:format) tasks#update
DELETE /tasks/:id(.:format) tasks#destroy
ゆっくり霊夢
「task_path とか tasks_path とかの名前もここで見えるのね。」
ゆっくり魔理沙 「そうだぜ。 ルーティングで迷ったらまずここを見るといい。」
3.3.3 HTMLレスポンスとしてのRails
この章では、Railsが基本的にHTMLを返していることが大事です。
たとえば index アクションはこうです。
def index @tasks = Task.all end
これだけで、Railsは暗黙的に次のビューを探します。
app/views/tasks/index.html.erb
つまり、
コントローラでインスタンス変数を用意する ↓ 対応するHTMLテンプレートで描画する ↓ ブラウザへHTMLを返す
という流れです。
この仕組みが、後のHotwireの「HTML over the wire」につながっていきます。
3.3.4 createアクションの流れを読む
create アクションはとても重要です。
def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end
ここで起きていることを分解するとこうです。
1. フォームから送られた値を受け取る 2. Taskオブジェクトを作る 3. 保存を試みる 4. 成功なら詳細画面へリダイレクトする 5. 失敗なら new テンプレートを再表示する
この「成功ならリダイレクト、失敗なら再描画」はRailsで非常によく出てきます。
3.3.5 strong parameters を理解する
task_params はセキュリティ上かなり重要です。
def task_params params.require(:task).permit(:title, :description, :status, :due_on) end
これにより、フォームから送信された値のうち、許可したものだけを使います。
たとえばフォームから余計なパラメータが送られてきても、許可されていなければ無視されます。
ゆっくり霊夢 「受け取る項目を明示してるのね。」
ゆっくり魔理沙 「そうだぜ。 “なんでも代入できる”状態は危ないからな。」
3.3.6 form_with がどう動いているか
フォームも見ておきましょう。
<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
<% if task.errors.any? %>
<div style="color: red">
<h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>
<ul>
<% task.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_area :description %>
</div>
<div>
<%= form.label :status, style: "display: block" %>
<%= form.text_field :status %>
</div>
<div>
<%= form.label :due_on, style: "display: block" %>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
form_with(model: task) は、task が新規か既存かによって送信先とHTTPメソッドを切り替えてくれます。
新規レコードの場合: POST /tasks 既存レコードの場合: PATCH /tasks/:id
これはRailsらしい便利さの代表例です。
3.3.7 ブラウザから見た流れ
たとえば新規作成では、ブラウザから見た流れはこうなります。
GET /tasks/new ↓ フォーム表示 POST /tasks ↓ 保存成功 → 302 Redirect → GET /tasks/:id 保存失敗 → 422 Unprocessable Entity + new再描画
この流れを頭で追えるようになると、後でTurbo化したときの差分も理解しやすくなります。
3.4 レイアウトとパーシャル整理
3.4.1 scaffoldのままだと少し読みにくい
ゆっくり霊夢 「scaffoldって便利だけど、見た目はちょっと素朴ね。」
ゆっくり魔理沙 「そうだぜ。 でも今大事なのはデザインじゃなくて、ビューを整理する感覚を身につけることだ。」
scaffold生成直後のビューは、学習用としては十分ですが、少しずつ整理したほうが後のHotwire化もしやすくなります。
3.4.2 application layout を整える
まず、全ページ共通のレイアウトを少しだけ整えます。
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>TaskApp</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<header>
<nav>
<%= link_to "TaskApp", tasks_path %> |
<%= link_to "New Task", new_task_path %>
</nav>
</header>
<% if notice.present? %>
<p style="color: green"><%= notice %></p>
<% end %>
<% if alert.present? %>
<p style="color: red"><%= alert %></p>
<% end %>
<main>
<%= yield %>
</main>
</body>
</html>
これで、各ページに共通ナビゲーションを持たせられます。
ゆっくり霊夢
「notice をレイアウトに移したのね。」
ゆっくり魔理沙 「そうだぜ。 各ビューに毎回書くより共通化したほうがすっきりする。」
3.4.3 indexビューを整理する
たとえば index を少し整理するとこうなります。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
新しく _task_card.html.erb を作ります。
<!-- app/views/tasks/_task_card.html.erb -->
<section class="task-card">
<h2><%= link_to task.title, task %></h2>
<p><strong>Status:</strong> <%= task.status %></p>
<p><strong>Due:</strong> <%= task.due_on %></p>
<% if task.description.present? %>
<p><%= task.description %></p>
<% end %>
</section>
ここでのポイントは、一覧用の見せ方と詳細用の見せ方を分け始めていることです。
3.4.4 showビューを整理する
詳細画面も少し整えます。
<!-- app/views/tasks/show.html.erb -->
<h1><%= @task.title %></h1>
<div class="task-detail">
<p>
<strong>Description:</strong><br>
<%= simple_format(@task.description) %>
</p>
<p>
<strong>Status:</strong>
<%= @task.status %>
</p>
<p>
<strong>Due on:</strong>
<%= @task.due_on %>
</p>
</div>
<div class="actions">
<%= link_to "Edit this task", edit_task_path(@task) %> |
<%= link_to "Back to tasks", tasks_path %>
<hr>
<%= button_to "Delete this task", @task, method: :delete %>
</div>
simple_format を使うと、複数行テキストを少し見やすく表示できます。
3.4.5 フォームを使い回す
new と edit でフォームが共通なのはRailsの定番です。
new.html.erb はこうです。
<!-- app/views/tasks/new.html.erb --> <h1>New task</h1> <%= render "form", task: @task %> <br> <div> <%= link_to "Back to tasks", tasks_path %> </div>
edit.html.erb はこうです。
<!-- app/views/tasks/edit.html.erb --> <h1>Edit task</h1> <%= render "form", task: @task %> <br> <div> <%= link_to "Show this task", @task %> | <%= link_to "Back to tasks", tasks_path %> </div>
フォーム本体は _form.html.erb にまとめています。
これがパーシャル分割の基本です。
3.4.6 status入力を select にする
少し改善して、status を自由入力ではなく選択式にしてみます。
# app/models/task.rb class Task < ApplicationRecord STATUSES = %w[todo doing done].freeze validates :title, presence: true validates :status, presence: true, inclusion: { in: STATUSES } end
フォームを修正します。
<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
<% if task.errors.any? %>
<div style="color: red">
<h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>
<ul>
<% task.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_area :description %>
</div>
<div>
<%= form.label :status, style: "display: block" %>
<%= form.select :status, Task::STATUSES.map { |status| [status.humanize, status] } %>
</div>
<div>
<%= form.label :due_on, style: "display: block" %>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
ゆっくり霊夢 「たしかにこっちのほうが入力ミスが減るわね。」
ゆっくり魔理沙 「そうだぜ。 ビューを少し整理するだけでも、かなりアプリらしくなる。」
3.4.7 パーシャル分割の考え方
ここで整理すると、パーシャルはこんな用途で分けると扱いやすいです。
_form.html.erb : new/edit 共有フォーム _task.html.erb : 汎用のtask表示 _task_card.html.erb : 一覧向けの見せ方
すべてを細かく分割すればいいわけではありませんが、繰り返し使う表示はパーシャルにしておくと後で便利です。
特にHotwireでは、部分更新の単位がそのままパーシャルと相性がよくなります。
3.4.8 ここまでのビュー構成
ここまでで、ビュー構成はだいたいこうなります。
app/views/tasks/ index.html.erb show.html.erb new.html.erb edit.html.erb _form.html.erb _task.html.erb _task_card.html.erb
このくらい整理されていると、次章以降でTurbo FrameやTurbo Streamを入れていくときも見通しが良くなります。
この章のまとめ
ゆっくり霊夢 「なるほど、Hotwireの前にまず普通のRails CRUDをちゃんと作る意味がわかってきたわ。」
ゆっくり魔理沙 「そうだぜ。この章のポイントをまとめるとこうなる。」
- 学習用の題材としてシンプルなTaskモデルを設計した - scaffoldを使ってCRUD一式を素早く作成した - resources によるRESTfulなルーティングを確認した - Railsは基本的にHTMLテンプレートを返す仕組みで動いている - create/updateでは成功時リダイレクト、失敗時再描画が基本になる - _form などのパーシャル分割でビューを整理できる - この整理が後のHotwire化で効いてくる
練習問題
問1
今回の Task モデルに持たせた属性を4つ挙げてください。
問2
次のルーティング定義で、自動生成されるCRUDの基本パスは何ですか。
resources :tasks
問3
create アクションで保存に失敗したとき、なぜ redirect_to ではなく render :new を使うのでしょうか。
問4
form_with(model: task) が便利な理由を説明してください。
問5
new と edit の両方で同じフォームを使いたい場合、どのようなファイル構成にするとよいでしょうか。
章末ミニコラム: なぜ最初は scaffold を使うのか
ゆっくり霊夢 「scaffoldって“本番では使わないから学ばなくていい”みたいに言われることもあるわよね。」
ゆっくり魔理沙 「そこは半分正しくて、半分違うぜ。」
たしかに、実務では scaffold 生成そのままのコードを使い続けるとは限りません。 でも学習の初期段階では、scaffoldにはかなり価値があります。
- Railsの標準CRUD構造を一気に見られる - モデル、コントローラ、ビュー、ルーティングのつながりが見える - “最低限動く形” をすぐ作れる - そこから自分で削ったり整理したりできる
大事なのは、scaffoldをゴールにしないことです。
scaffoldで土台を作る ↓ コードを読む ↓ 不要なものを削る ↓ 必要な形に整理する ↓ Hotwire対応へ育てる
この順番なら、scaffoldはかなり強い味方になります。
ゆっくり魔理沙 「最初から全部手書きでやるのも勉強にはなるけど、全体像を早く掴むなら scaffold は便利なんだぜ。」
Chapter 4: Turbo Driveでページ遷移を高速化
はじめに
ゆっくり霊夢 「ここまでで普通のCRUDアプリはできたけど、まだ“Hotwireすごい!”感はそこまでないわね。」
ゆっくり魔理沙 「そうだぜ。第4章から、いよいよHotwireの主役である Turbo に入っていく。まずは一番基本の Turbo Drive だ。」
ゆっくり霊夢 「Turbo Driveって、何をしてくれるの?」
ゆっくり魔理沙 「ざっくり言うと、リンククリックやフォーム送信をフックして、ページ全体を毎回まるごと再読み込みしないようにする仕組みだぜ。」
ゆっくり霊夢 「じゃあ見た目は普通のRailsアプリなのに、体感が少しSPAっぽくなるのね。」
ゆっくり魔理沙 「その通りだ。しかも、こっちがやることは意外と少ない。だからこそ、“何が起きているか”をちゃんと理解するのが大事なんだぜ。」
この章では、次の4つを扱います。
- 4.1 Turbo Driveの仕組み
- 4.2 リンククリックの変化
- 4.3 フォーム送信の挙動
- 4.4 キャッシュと注意点
4.1 Turbo Driveの仕組み
4.1.1 Turbo Driveとは何か
ゆっくり霊夢 「まずは原理から知りたいわ。」
ゆっくり魔理沙 「いい心がけだぜ。Turbo Driveは、リンククリックやフォーム送信をそのままブラウザ任せにせず、JavaScript側でいったん受け取る。そして裏側でHTMLを取りに行って、必要な部分を差し替えるんだ。」
普通のブラウザ遷移は、ざっくりこうです。
リンククリック ↓ ブラウザが新しいURLへ移動 ↓ 現在のページを破棄 ↓ 新しいHTMLを受信 ↓ ページ全体を再構築
Turbo Driveありだと、イメージはこうなります。
リンククリック ↓ Turboがイベントを受け取る ↓ 裏側でHTMLを取得する ↓ <body>を中心に差し替える ↓ ページ遷移っぽく見せる
ポイントは、「見た目は普通のページ遷移だが、内部ではより賢く処理している」ことです。
4.1.2 まずは application.js を確認する
Turbo Driveを有効にしている中心は、この1行です。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers"
この @hotwired/turbo-rails を読み込むことで、Turbo Drive がリンクやフォームを監視するようになります。
ゆっくり霊夢 「たった1行でそんなに変わるの?」
ゆっくり魔理沙 「そうだぜ。だからこそ、便利なんだけど“何が変わったか気づきにくい”とも言える。」
4.1.3 何が差し替えられているのか
Turbo Driveは、完全なSPAではありません。 JavaScriptで画面全体を再構築するのではなく、サーバーから返ってきたHTMLを使うのが前提です。
たとえば、TasksController#show はこうです。
# app/controllers/tasks_controller.rb def show end
対応するビューはこうです。
<!-- app/views/tasks/show.html.erb --> <h1><%= @task.title %></h1> <p><strong>Status:</strong> <%= @task.status %></p> <p><strong>Due on:</strong> <%= @task.due_on %></p> <%= link_to "Back to tasks", tasks_path %>
Turbo Driveが有効な場合でも、サーバーは今まで通り 普通のHTML を返しています。 違うのは、「そのHTMLをブラウザがどう適用するか」です。
4.1.4 Turbo Driveをイベントで観察する
まずは、実際にTurbo Driveが動いていることをログで見てみましょう。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers" document.addEventListener("turbo:click", (event) => { console.log("turbo:click", event.target) }) document.addEventListener("turbo:before-visit", (event) => { console.log("turbo:before-visit", event.detail.url) }) document.addEventListener("turbo:visit", (event) => { console.log("turbo:visit", event.detail.url) }) document.addEventListener("turbo:load", () => { console.log("turbo:load") })
ブラウザの開発者ツールを開いて、/tasks から Show this task をクリックしてみてください。
ログのイメージはこんな感じです。
turbo:click <a href="/tasks/1">...</a> turbo:before-visit http://localhost:3000/tasks/1 turbo:visit http://localhost:3000/tasks/1 turbo:load
ゆっくり霊夢 「おお、ちゃんとTurboが動いてるのが見えるわ。」
ゆっくり魔理沙 「そうだぜ。最初は“体感で速い気がする”より、イベントで確認したほうが理解が深まる。」
4.1.5 通常のページ遷移との違い
Turbo Driveなしのページ遷移では、JavaScriptの状態もDOMもページ全体も完全にリセットされます。
Turbo Driveありでは、ページ遷移の見た目は似ていますが、内部では次のような違いがあります。
- リンククリックをTurboが拾う - fetchに近い形でHTMLを取得する - 新しいHTMLを解析する - titleやbodyを更新する - 遷移完了イベントを発火する
このおかげで、フルリロードよりも軽快に感じやすいわけです。
4.1.6 “SPAではない”ことを忘れない
ゆっくり霊夢 「なんだかもうSPAっぽいわね。」
ゆっくり魔理沙 「でもそこが大事なところで、Turbo DriveはSPAそのものじゃない。 あくまで サーバー側HTMLを使うMPAを賢くする仕組み なんだ。」
整理すると、こんな違いがあります。
SPA: - JSONを受け取ってクライアントで描画 - 状態管理はクライアント主役 Turbo Drive: - HTMLを受け取って差し替える - 表示の主役はサーバー側テンプレート
4.2 リンククリックの変化
4.2.1 普通の link_to がTurbo対応になる
ゆっくり霊夢 「Turbo Driveを使うために、リンクを特別な書き方に変える必要はあるの?」
ゆっくり魔理沙
「基本はないぜ。普通の link_to でそのまま効くのが大きな魅力だ。」
たとえば一覧画面のリンクです。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
<%= link_to "New task", new_task_path %>
パーシャル側。
<!-- app/views/tasks/_task_card.html.erb --> <section class="task-card"> <h2><%= link_to task.title, task_path(task) %></h2> <p><strong>Status:</strong> <%= task.status %></p> <p><strong>Due:</strong> <%= task.due_on %></p> </section>
この普通のリンクが、Turbo Driveによって高速化されます。
4.2.2 ローディング表示を足してみる
Turbo Driveの体感をつかみやすくするために、画面上に簡単なローディング状態を出してみましょう。
まずはレイアウトにプレースホルダを置きます。
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>TaskApp</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<div id="loading-indicator" style="display: none;">
Loading...
</div>
<header>
<nav>
<%= link_to "TaskApp", tasks_path %> |
<%= link_to "New Task", new_task_path %>
</nav>
</header>
<% if notice.present? %>
<p style="color: green"><%= notice %></p>
<% end %>
<main>
<%= yield %>
</main>
</body>
</html>
次に JavaScript です。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers" const showLoading = () => { const indicator = document.getElementById("loading-indicator") if (indicator) indicator.style.display = "block" } const hideLoading = () => { const indicator = document.getElementById("loading-indicator") if (indicator) indicator.style.display = "none" } document.addEventListener("turbo:before-visit", showLoading) document.addEventListener("turbo:load", hideLoading)
これで、ページ遷移時に Loading... が一瞬出るようになります。
ゆっくり霊夢 「なるほど、リンククリックをTurboが横取りしてる感じが視覚的にわかるわね。」
ゆっくり魔理沙 「そうだぜ。あとでもっと綺麗なUIにできるけど、まずは仕組みを感じるのが大事だ。」
4.2.3 Turboを無効にしたリンクと比較する
違いを実感するために、1本だけTurboを切ってみましょう。
<!-- app/views/tasks/show.html.erb -->
<h1><%= @task.title %></h1>
<p><strong>Status:</strong> <%= @task.status %></p>
<p><strong>Due on:</strong> <%= @task.due_on %></p>
<p>
<%= link_to "Back to tasks (Turbo ON)", tasks_path %>
</p>
<p>
<%= link_to "Back to tasks (Turbo OFF)", tasks_path, data: { turbo: false } %>
</p>
data-turbo="false" を付けたリンクは、通常のブラウザ遷移になります。
この違いを整理するとこうです。
Turbo ON: - Turboがイベントを拾う - 高速なページ遷移になる Turbo OFF: - ブラウザ標準の遷移 - 完全なフルリロードになる
4.2.4 特定のリンクだけ通常遷移にしたい場面
Turbo Driveは便利ですが、全部に効かせればよいわけではありません。
たとえば次のような場合は、通常遷移のほうが扱いやすいことがあります。
- 外部サイトへの遷移 - 特殊な初期化が必要なページ - 切り分けのため一時的に無効化したいとき - JavaScriptライブラリとの相性問題があるとき
書き方はこれだけです。
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>
ゆっくり霊夢 「Turboが賢いからって、全部任せきりじゃなくていいのね。」
ゆっくり魔理沙 「そうだぜ。 “必要に応じて切れる” という柔らかさが、現実のアプリではかなり大事なんだ。」
4.2.5 current pageの見え方は変わらない
Turbo Driveを使っても、Rails側のリンクヘルパーやルーティングの考え方はほとんど変わりません。
<%= link_to "Tasks", tasks_path %> <%= link_to "New Task", new_task_path %> <%= link_to @task.title, task_path(@task) %>
つまり、ビューの書き方は従来のRailsのままでよいのです。 これが、Turbo Driveの導入障壁をかなり下げています。
4.3 フォーム送信の挙動
4.3.1 フォーム送信もTurboの対象になる
ゆっくり霊夢 「リンクだけじゃなくて、フォーム送信もTurboが面倒を見てくれるの?」
ゆっくり魔理沙 「そうだぜ。しかもCRUDアプリでは、むしろこっちの恩恵が大きい。」
たとえばフォームはこうでした。
<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
<% if task.errors.any? %>
<div style="color: red">
<h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>
<ul>
<% task.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_area :description %>
</div>
<div>
<%= form.label :status, style: "display: block" %>
<%= form.select :status, Task::STATUSES.map { |status| [status.humanize, status] } %>
</div>
<div>
<%= form.label :due_on, style: "display: block" %>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
この普通の form_with も、Turbo Driveの対象です。
4.3.2 create の成功時
まず、コントローラの create を見ます。
# app/controllers/tasks_controller.rb def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end
作成が成功すると、
POST /tasks ↓ 保存成功 ↓ redirect_to @task ↓ GET /tasks/:id
となります。
Turbo Driveが有効な場合、このリダイレクト後の遷移も自然につながります。
ゆっくり霊夢 「つまり、サーバー側コードはそんなに変えなくていいのね。」
ゆっくり魔理沙 「そこがうまいところだぜ。 Turbo Driveは、まず既存のRails流儀を活かす設計なんだ。」
4.3.3 create の失敗時
失敗時はこちらです。
def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end
失敗すると、new テンプレートが再描画されます。
POST /tasks ↓ 保存失敗 ↓ render :new ↓ 入力値とエラー付きのフォーム再表示
ここでもTurbo Driveは自然に動きます。 ユーザーから見ると、フォーム送信後にエラー表示つきの画面へ戻ってきます。
4.3.4 フォーム送信イベントを観察する
フォーム送信時のTurboイベントも見てみましょう。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers" document.addEventListener("turbo:submit-start", (event) => { console.log("turbo:submit-start", event.target) }) document.addEventListener("turbo:submit-end", (event) => { console.log("turbo:submit-end", event.detail) })
新規作成フォームを送信すると、ログが出ます。
turbo:submit-start <form ...>
turbo:submit-end { formSubmission: ..., success: true, fetchResponse: ... }
バリデーションエラー時は success の見え方やレスポンスの内容が異なります。
このログ確認は、後でTurbo FramesやTurbo Streamsに進んだときにも役立ちます。
4.3.5 送信中にボタンを無効化する
フォーム送信中に二重送信を防ぐ簡単な例もやってみましょう。
まずフォームのsubmitボタンにidを付けます。
<!-- app/views/tasks/_form.html.erb --> <div> <%= form.submit "Save task", id: "task-submit-button" %> </div>
JavaScript側です。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers" document.addEventListener("turbo:submit-start", () => { const button = document.getElementById("task-submit-button") if (button) { button.disabled = true button.value = "Saving..." } }) document.addEventListener("turbo:submit-end", () => { const button = document.getElementById("task-submit-button") if (button) { button.disabled = false button.value = "Save task" } })
これで送信中はボタンが押せなくなります。
ゆっくり霊夢 「こういうちょっとした快適さ、いいわね。」
ゆっくり魔理沙 「そうだぜ。しかも大がかりなフロント実装なしでできるのが良い。」
4.3.6 Turboを切ったフォームも試してみる
比較のため、フォームでTurboを無効にしてみます。
<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task, data: { turbo: false }) do |form| %>
...
<% end %>
この場合は、従来どおりの通常送信になります。
Turbo ON: - Turboが送信をフック - 遷移や再描画が滑らか Turbo OFF: - ブラウザ標準送信 - フルリロード前提
ただし、基本的には まずTurbo ONのまま使う 方向で考えるのがおすすめです。
4.3.7 POST-redirect-GET を再確認する
RailsのCRUDでは、成功時に redirect_to、失敗時に render が基本でした。
Turbo Driveでもこの考え方はそのまま重要です。
if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end
この構成は、次の意味を持っています。
成功: - URLをきちんと新しいページへ移す - リロードしても安全 失敗: - 入力内容とエラーを保ったままフォームを再表示
Hotwireを使っても、Railsの王道の流れを守ることが大切です。
4.4 キャッシュと注意点
4.4.1 Turbo Driveはページをキャッシュする
ゆっくり霊夢 「Turbo Driveって、ただ速くしてるだけじゃないの?」
ゆっくり魔理沙 「実はそれだけじゃない。Turbo Driveはページをキャッシュして、戻る・進むの体感をさらに良くしているんだ。」
たとえば、
/tasks ↓ /tasks/1 ↓ ブラウザで戻る
という操作のとき、Turboはキャッシュ済みのページを使って素早く戻すことがあります。
これ自体はかなり便利です。 でも、“前に見たDOMが一瞬戻ってくる” という特徴があるので、理解しておかないと戸惑うことがあります。
4.4.2 一時的なDOM状態が残ることがある
たとえば、ページ上にJavaScriptで一時的な表示を足したとします。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<p id="temporary-message"></p>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
document.addEventListener("turbo:load", () => { const message = document.getElementById("temporary-message") if (message) { message.textContent = "Welcome back!" } })
このような処理では、キャッシュ復元時の見え方に注意が必要です。 「初回表示」「通常遷移」「戻る操作」で、少し挙動が違って見えることがあります。
4.4.3 ページごとの初期化は turbo:load に寄せる
Turbo Drive環境では、従来の DOMContentLoaded だけに頼ると不十分になることがあります。
悪い例です。
document.addEventListener("DOMContentLoaded", () => { console.log("This may run only once") })
おすすめは、Turbo環境では turbo:load を使うことです。
document.addEventListener("turbo:load", () => { console.log("This runs after each Turbo visit") })
ゆっくり霊夢 「なるほど。ページ遷移してるように見えても、普通のフルリロードとは限らないから、初期化イベントも変わるのね。」
ゆっくり魔理沙 「その通りだぜ。ここはTurbo入門でかなり大事な落とし穴なんだ。」
4.4.4 ページごとのJavaScriptを雑に書かない
Turbo Driveを使うと、こんなコードは危険になりやすいです。
document.addEventListener("turbo:load", () => { const button = document.getElementById("danger-button") button.addEventListener("click", () => { alert("clicked") }) })
一見普通ですが、ページ遷移や再訪問のたびにイベントが重複する可能性があります。
少し安全にするなら、存在確認を入れたり、Stimulusへ寄せたりします。
document.addEventListener("turbo:load", () => { const button = document.getElementById("danger-button") if (!button) return button.onclick = () => { alert("clicked") } })
よりHotwireらしいやり方は、こういう細かな挙動を Stimulus controller に任せることです。 Turbo Driveで遷移、Stimulusで局所的な振る舞い、という役割分担がここでも効きます。
4.4.5 キャッシュされたくないページもある
特定のページでは、Turboのキャッシュや高速遷移が相性の悪いことがあります。
たとえば、
- 毎回フレッシュに表示したい画面 - 特殊な外部ライブラリ初期化が必要な画面 - セキュリティ上、戻る時の見え方に気をつけたい画面
そういう場合は、ページ側で再読み込みを要求できます。
<!-- app/views/tasks/special.html.erb --> <%= turbo_page_requires_reload %> <h1>Special page</h1> <p>This page always requires a full reload.</p>
これを使うと、そのページではTurbo Driveではなく通常リロード寄りの動きになります。
4.4.6 asset変更時の注意
レイアウトでこう書いていました。
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
必要に応じて、JavaScriptやCSSアセットに data-turbo-track="reload" を付けることで、アセットが変わったときに再読み込みを促せます。
たとえばレイアウトでこうです。
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %>
このあたりは普段あまり意識しなくても動きますが、「見た目やJSを変えたのに古い状態が残る」 と感じたら確認ポイントになります。
4.4.7 デバッグ時の基本姿勢
Turbo Driveで困ったときは、次の順番で切り分けると整理しやすいです。
1. まず turbo:load などのイベントログを見る 2. data-turbo="false" で通常遷移にして差分を見る 3. 初期化コードが DOMContentLoaded 依存になっていないか確認する 4. 画面固有のJavaScriptを Stimulus に移せないか考える 5. 必要なら turbo_page_requires_reload を検討する
ゆっくり霊夢 「“Turboが悪い”って決めつける前に、まずイベントと初期化タイミングを見るのが大事なのね。」
ゆっくり魔理沙 「そうだぜ。Turboは便利だけど、普通のフルリロード前提の癖が残ってると混乱しやすいんだ。」
この章のまとめ
ゆっくり霊夢 「だいぶわかってきたわ。Turbo Driveって、見た目は普通のRails遷移なのに、裏側だけ賢くしてくれる仕組みなのね。」
ゆっくり魔理沙 「その理解でかなりいいぜ。第4章のポイントをまとめるとこうだ。」
- Turbo Driveはリンククリックやフォーム送信をフックして高速化する - サーバーは今までどおりHTMLを返す - 普通の link_to や form_with がそのままTurbo対応になる - 成功時 redirect_to、失敗時 render というRailsの基本はそのまま重要 - Turbo環境では DOMContentLoaded より turbo:load を意識する - キャッシュの存在を理解すると、“なぜこの挙動になるのか” を説明しやすい - 問題が起きたら、Turboを一時的に無効化して差分を見ると切り分けしやすい
練習問題
問1
Turbo Drive が高速化している対象を2つ挙げてください。
問2
次のコードで、Turbo Driveを有効にしている中心となる1行はどれですか。
import "@hotwired/turbo-rails" import "controllers"
問3
リンクを通常のブラウザ遷移にしたい場合、どのように書きますか。
問4
Turbo環境で、ページごとの初期化処理を実行するイベントとして何を使うのが基本ですか。
問5
次の create アクションで、成功時と失敗時にそれぞれ何が起きるか説明してください。
def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end
章末ミニコラム: Turbo Driveは“地味”だが強い
ゆっくり霊夢 「正直、Turbo FrameやTurbo Streamのほうが派手でわかりやすそうね。」
ゆっくり魔理沙 「それはそうなんだが、Turbo Driveはかなり重要なんだぜ。むしろ“何もしないのに全体の体感を良くする”のが強い。」
Turbo Driveのよさは、次のような点にあります。
- 既存のRailsアプリに入りやすい - link_to と form_with がそのまま活きる - サーバーサイドHTML中心の設計を崩さない - いきなり複雑なUIを書かなくても恩恵がある
派手さは少ないですが、アプリ全体の基礎体験を底上げするという意味ではかなり大事です。
ゆっくり魔理沙 「Turbo FrameやTurbo Streamはこの上に乗るからな。まずDriveを理解しておくと、後の章がかなり楽になるんだぜ。」
Chapter 5: Turbo Framesで部分更新
はじめに
ゆっくり霊夢 「Turbo Driveはわかったわ。リンククリックやフォーム送信がちょっと賢くなるやつよね。」
ゆっくり魔理沙 「そうだぜ。でもTurbo Driveだけだと、基本はまだ“ページ単位の遷移”なんだ。ここから一歩進んで、画面の一部分だけを差し替えるのが Turbo Frames だぜ。」
ゆっくり霊夢 「お、だんだんSPAっぽくなってきた感じ。」
ゆっくり魔理沙 「見た目はそうだな。でも中身は相変わらずRailsのHTMLだ。 つまり、サーバーで作ったHTMLの一部分だけを、狙って差し替えるのがTurbo Framesなんだ。」
この章では、次の4つを扱います。
- 5.1 Turbo Framesとは
- 5.2 一覧と詳細の分離
- 5.3 モーダルUIの実装
- 5.4 ネストと落とし穴
5.1 Turbo Framesとは
5.1.1 Turbo Framesの基本イメージ
ゆっくり霊夢 「まず、Turbo Framesって何を囲うの?」
ゆっくり魔理沙 「答えはシンプルで、“あとで差し替えたい画面の一部分” だぜ。」
Turbo Framesは、HTMLの一部をこう囲います。
<%= turbo_frame_tag "task_details" do %> <p>Select a task</p> <% end %>
これで、task_details という名前の更新対象ができます。
イメージとしてはこうです。
ページ全体 ├─ ヘッダー ├─ サイドバー ├─ 一覧 └─ task_details フレーム ← ここだけ差し替えたい
つまり、ページ全体を更新するのではなく、特定の領域だけをHTMLで入れ替えるわけです。
5.1.2 まずは最小例を見る
たとえば、タスク詳細部分だけを切り替えたいとします。
一覧ページ側にこう書きます。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<ul>
<% @tasks.each do |task| %>
<li>
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
</li>
<% end %>
</ul>
<%= turbo_frame_tag "task_details" do %>
<p>Please select a task.</p>
<% end %>
詳細画面側はこうです。
<!-- app/views/tasks/show.html.erb --> <%= turbo_frame_tag "task_details" do %> <h2><%= @task.title %></h2> <p><strong>Status:</strong> <%= @task.status %></p> <p><strong>Due on:</strong> <%= @task.due_on %></p> <p><%= @task.description %></p> <% end %>
これで、一覧のリンクをクリックすると、ページ全体ではなく task_details フレームの中だけが更新されます。
ゆっくり霊夢 「おお、これかなり気持ちいいわね。」
ゆっくり魔理沙 「そうだぜ。 しかもサーバーは普通にHTMLを返してるだけなのがポイントだ。」
5.1.3 “同じidのフレームを返す”のが大事
Turbo Framesでは、リクエスト先のレスポンス側にも同じ名前のフレームが必要です。
つまりこういう対応です。
一覧側: data-turbo-frame="task_details" レスポンス側: <turbo-frame id="task_details"> ... </turbo-frame>
Railsでは turbo_frame_tag を使えば自然に書けます。
<%= turbo_frame_tag "task_details" do %> ... <% end %>
これを忘れると、期待どおりに部分更新されません。
5.1.4 フレームを使わない場合との比較
Turbo Driveだけだと、タスク詳細はこうなっていました。
<%= link_to task.title, task_path(task) %>
この場合は通常のページ遷移です。
Turbo Framesを使うとこうです。
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
違いを整理するとこうなります。
Turbo Drive: - ページ単位で賢く遷移する Turbo Frames: - ページの一部分だけを狙って差し替える
ゆっくり霊夢 「つまりFramesはDriveの上に乗る“もっと細かい更新”なのね。」
ゆっくり魔理沙 「その理解でかなりいいぜ。」
5.1.5 フレームが向いている場面
Turbo Framesは、次のような場面で特に強いです。
- 一覧と詳細を同じ画面に並べたい - フォームだけ差し替えたい - サイドパネルだけ更新したい - モーダルの中身だけ読み込みたい - タブの中身だけ非同期で切り替えたい
逆に、ページ全体の遷移で十分なところに無理にFramesを入れる必要はありません。
5.2 一覧と詳細の分離
5.2.1 二カラムUIにしてみる
ゆっくり霊夢 「Framesのよさが一番わかりやすいのは、やっぱり一覧と詳細を並べるUIかしら。」
ゆっくり魔理沙 「その通りだぜ。じゃあ今のタスク管理アプリを、左に一覧、右に詳細 という構成にしてみよう。」
まず index をこう変えます。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<div class="tasks-layout">
<section class="tasks-list">
<ul>
<% @tasks.each do |task| %>
<li>
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
</li>
<% end %>
</ul>
<p>
<%= link_to "New task", new_task_path, data: { turbo_frame: "task_details" } %>
</p>
</section>
<section class="tasks-detail">
<%= turbo_frame_tag "task_details" do %>
<p>Select a task to see the details.</p>
<% end %>
</section>
</div>
ちょっとしたCSSも足しておきます。
/* app/assets/stylesheets/application.css */ .tasks-layout { display: grid; grid-template-columns: 280px 1fr; gap: 24px; align-items: start; } .tasks-list { border-right: 1px solid #ddd; padding-right: 16px; } .tasks-detail { min-height: 240px; }
これで、一覧と詳細が並んだ画面になります。
5.2.2 show を詳細パネル向けにする
次に show.html.erb を、フレーム差し替え前提の表示に整えます。
<!-- app/views/tasks/show.html.erb -->
<%= turbo_frame_tag "task_details" do %>
<h2><%= @task.title %></h2>
<p>
<strong>Status:</strong>
<%= @task.status %>
</p>
<p>
<strong>Due on:</strong>
<%= @task.due_on %>
</p>
<p>
<strong>Description:</strong><br>
<%= simple_format(@task.description) %>
</p>
<div class="actions">
<%= link_to "Edit", edit_task_path(@task), data: { turbo_frame: "task_details" } %>
|
<%= link_to "Back to list", tasks_path %>
</div>
<% end %>
ここで Edit も同じフレームを更新するようにしているのがポイントです。
5.2.3 new と edit もフレーム対応にする
新規作成フォームも、右側パネルに表示できるようにします。
<!-- app/views/tasks/new.html.erb --> <%= turbo_frame_tag "task_details" do %> <h2>New task</h2> <%= render "form", task: @task %> <% end %>
編集画面も同様です。
<!-- app/views/tasks/edit.html.erb -->
<%= turbo_frame_tag "task_details" do %>
<h2>Edit task</h2>
<%= render "form", task: @task %>
<p>
<%= link_to "Show task", task_path(@task), data: { turbo_frame: "task_details" } %>
</p>
<% end %>
5.2.4 保存後の挙動を確認する
コントローラは、基本的にはそのままでも動きます。
# app/controllers/tasks_controller.rb def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end def update if @task.update(task_params) redirect_to @task, notice: "Task was successfully updated." else render :edit, status: :unprocessable_entity end end
ここでのポイントは、Turbo Frames内でフォーム送信されたあと、リダイレクト先の show も同じフレームを返せば、そのフレーム内だけで遷移が完結することです。
ゆっくり霊夢 「コントローラを大きく書き換えなくていいの、やっぱり強いわね。」
ゆっくり魔理沙 「そうだぜ。 Turbo Framesは“Railsの流れを壊さずに部分更新を作れる”のが本当にうまい。」
5.2.5 一覧側も少し見やすくする
一覧パーシャルを用意して、選択対象として見やすくしてみます。
<!-- app/views/tasks/_task_link.html.erb -->
<li id="<%= dom_id(task, :link) %>">
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
<small>(<%= task.status %>)</small>
</li>
index はこうです。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<div class="tasks-layout">
<section class="tasks-list">
<ul>
<% @tasks.each do |task| %>
<%= render "task_link", task: task %>
<% end %>
</ul>
<p>
<%= link_to "New task", new_task_path, data: { turbo_frame: "task_details" } %>
</p>
</section>
<section class="tasks-detail">
<%= turbo_frame_tag "task_details" do %>
<p>Select a task to see the details.</p>
<% end %>
</section>
</div>
こうしておくと、後でTurbo Streamsを組み合わせるときにも相性がよくなります。
5.2.6 一覧と詳細の責務を分ける意識
ここで大事なのは、“一覧は一覧、詳細は詳細” と責務を分けることです。
一覧: - タスクを選ぶ - 新規作成を始める 詳細: - タスクの内容を見る - 編集する - 詳細系の操作をする
この分離をしておくと、後でモーダルやサイドパネルにも発展させやすくなります。
5.3 モーダルUIの実装
5.3.1 Turbo Framesでモーダルを作る発想
ゆっくり霊夢 「一覧と詳細はわかったけど、モーダルもFramesでできるの?」
ゆっくり魔理沙 「できるぜ。むしろFramesの定番のひとつだ。 発想はシンプルで、モーダルの中身を入れる空フレームをレイアウトに置いておく んだ。」
まず、レイアウトにモーダル用の領域を追加します。
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>TaskApp</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<header>
<nav>
<%= link_to "TaskApp", tasks_path %>
</nav>
</header>
<% if notice.present? %>
<p style="color: green"><%= notice %></p>
<% end %>
<main>
<%= yield %>
</main>
<%= turbo_frame_tag "modal" %>
</body>
</html>
この modal フレームが、あとでモーダルの中身に使われます。
5.3.2 New task をモーダルで開く
一覧画面のリンクを変えます。
<!-- app/views/tasks/index.html.erb -->
<p>
<%= link_to "New task in modal", new_task_path, data: { turbo_frame: "modal" } %>
</p>
すると new_task_path のレスポンスのうち、id="modal" のフレームが差し込まれます。
5.3.3 new.html.erb をモーダル用にする
new.html.erb をこうします。
<!-- app/views/tasks/new.html.erb -->
<%= turbo_frame_tag "modal" do %>
<div class="modal-backdrop">
<div class="modal-window">
<h2>New task</h2>
<%= render "form", task: @task %>
<p>
<%= link_to "Close", tasks_path, data: { turbo_frame: "_top" } %>
</p>
</div>
</div>
<% end %>
CSSも足します。
/* app/assets/stylesheets/application.css */ .modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: grid; place-items: center; } .modal-window { background: white; padding: 24px; width: min(600px, 90vw); border-radius: 12px; }
これで、新規作成フォームがモーダル風に開きます。
ゆっくり霊夢 「おお、かなりそれっぽい!」
ゆっくり魔理沙 「そうだぜ。しかもJavaScriptでモーダルDOMを組み立ててるわけじゃない。 サーバー側HTMLをそのまま差し込んでるんだ。」
5.3.4 _top の意味を理解する
ここで出てきたこれが重要です。
data: { turbo_frame: "_top" }
_top は、フレーム内ではなくページ全体として遷移する という意味です。
たとえばモーダルのCloseリンクでこう書きました。
<%= link_to "Close", tasks_path, data: { turbo_frame: "_top" } %>
これにより、modal フレームの中だけを更新するのではなく、ページ全体として /tasks に戻れます。
5.3.5 保存成功後にモーダルを閉じたい場合
ここでよく出る要望がこれです。
ゆっくり霊夢 「フォーム保存が成功したら、モーダルを閉じて一覧に戻したいわ。」
ゆっくり魔理沙 「そうだな。最初は一番シンプルな方法からいこう。 成功時はページ全体へ遷移させるんだ。」
コントローラで分岐を書く方法もありますが、まずはビュー側でシンプルに作るなら、モーダルのフォーム送信後の遷移先を普通に一覧や詳細へ戻す設計で十分です。
ただ、より自然な「モーダルだけ閉じる」は、後のTurbo Streams章でかなり綺麗にできます。 この章では、まずFramesでモーダルを開けることを重視します。
5.3.6 編集モーダルも同じ考え方でできる
編集もまったく同じです。
<!-- app/views/tasks/show.html.erb -->
<%= turbo_frame_tag "task_details" do %>
<h2><%= @task.title %></h2>
<p><strong>Status:</strong> <%= @task.status %></p>
<p><strong>Due on:</strong> <%= @task.due_on %></p>
<p>
<%= link_to "Edit in modal", edit_task_path(@task), data: { turbo_frame: "modal" } %>
</p>
<% end %>
edit.html.erb はこうできます。
<!-- app/views/tasks/edit.html.erb -->
<%= turbo_frame_tag "modal" do %>
<div class="modal-backdrop">
<div class="modal-window">
<h2>Edit task</h2>
<%= render "form", task: @task %>
<p>
<%= link_to "Close", task_path(@task), data: { turbo_frame: "_top" } %>
</p>
</div>
</div>
<% end %>
5.3.7 モーダルに向いているケース
Turbo Framesモーダルは、次のようなケースで特に便利です。
- 新規作成フォーム - 編集フォーム - 確認ダイアログ風の詳細表示 - 一覧を崩さず補助UIを出したいとき
ただし、アニメーションやフォーカス制御やEscキー対応までしっかりやるなら、Stimulusと組み合わせるのが自然です。 この章ではまず、「Framesだけでも十分モーダルっぽく作れる」を掴めればOKです。
5.4 ネストと落とし穴
5.4.1 フレームは便利だが、増やしすぎると混乱する
ゆっくり霊夢 「Frames、かなり便利ね。 じゃあ画面のあちこちを全部フレームにしたくなってきたわ。」
ゆっくり魔理沙 「その気持ちはわかるが、そこが落とし穴なんだぜ。」
Turbo Framesは便利ですが、増やしすぎると次のような混乱が起きます。
- どのリンクがどのフレームを更新するのかわかりにくい - レスポンスにどのフレームidが必要か迷う - 部分更新の責務があいまいになる - フレームの中にフレームが増えて追いにくくなる
5.4.2 ネストした例を見る
たとえばこんな構成は作れてしまいます。
<%= turbo_frame_tag "task_details" do %>
<h2><%= @task.title %></h2>
<%= turbo_frame_tag "comments" do %>
<p>No comments yet</p>
<% end %>
<% end %>
これは技術的には可能です。 でも、リンクやフォームがどこを更新するのかが複雑になりやすいです。
たとえば、
<%= link_to "Load comments", comments_task_path(@task), data: { turbo_frame: "comments" } %>
のように内側フレームだけ更新したいケースはあります。 ただし、これが増えすぎると読み手がつらくなります。
5.4.3 フレーム名を曖昧にしない
よくない例です。
<%= turbo_frame_tag "content" do %> ... <% end %>
content や main のような曖昧な名前は、あとで何を入れる場所なのかわかりにくくなります。
おすすめは、役割がわかる名前です。
<%= turbo_frame_tag "task_details" do %> ... <% end %> <%= turbo_frame_tag "modal" do %> ... <% end %> <%= turbo_frame_tag dom_id(@task) do %> ... <% end %>
ゆっくり霊夢 「“何を表示するフレームか” が名前でわかるようにするのね。」
ゆっくり魔理沙 「そうだぜ。 後で保守する自分に優しくするんだ。」
5.4.4 フレームレスポンス不一致の罠
Turbo Framesでよくあるミスがこれです。
一覧側:
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
なのに、レスポンス側がこうなっている。
<%= turbo_frame_tag "details" do %> ... <% end %>
これではフレーム名が一致していません。
正しくはこうです。
<%= turbo_frame_tag "task_details" do %> ... <% end %>
つまり、更新対象として指定した名前と、返ってくるフレームidは一致させる必要があります。
5.4.5 フレーム内リンクが意図せず閉じた世界になる
もうひとつ大事なポイントがあります。 フレーム内にあるリンクやフォームは、何も指定しないとそのフレーム内で完結しやすいです。
たとえばモーダルの中でこう書くとします。
<%= turbo_frame_tag "modal" do %> <p>Task created.</p> <%= link_to "Go to tasks", tasks_path %> <% end %>
このリンクは、何も指定しないと modal フレームの中を /tasks で更新しようとします。
つまり、モーダルの中に一覧画面が入ってしまうことがあります。
それを避けるには _top を使います。
<%= link_to "Go to tasks", tasks_path, data: { turbo_frame: "_top" } %>
これはかなり重要です。
5.4.6 バリデーションエラー時の見え方を確認する
フォーム系でFramesを使うときは、失敗時の挙動を必ず確認しましょう。
create が失敗した場合:
def create @task = Task.new(task_params) if @task.save redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end
このとき new.html.erb がフレームで包まれていれば、エラー付きフォームがそのフレーム内に再表示されます。
<%= turbo_frame_tag "modal" do %> <h2>New task</h2> <%= render "form", task: @task %> <% end %>
これはかなり自然なUXになります。 だからこそ、フォーム系はFramesと相性がよいわけです。
5.4.7 どこまでFramesでやるかの判断基準
最後に、Framesを使うかどうかの目安を整理します。
Frames向き:
- 1つの意味ある表示領域を差し替えたい - 一覧と詳細を同居させたい - フォームだけ差し替えたい - モーダルの中身を読み込みたい
Framesを増やしすぎないほうがよい場面:
- 画面全体の文脈ごと切り替えたい - 更新単位が曖昧 - どの領域が主役かわからない - ネストが深くなりすぎる
ゆっくり霊夢 「なんでもFramesじゃなくて、“意味のある更新単位” に絞るのが大事なのね。」
ゆっくり魔理沙 「その通りだぜ。 フレームは便利だけど、設計のセンスがそのまま出るところなんだ。」
この章のまとめ
ゆっくり霊夢 「Turbo Frames、かなり実用的ね。 ページ全部じゃなくて“一部分だけ更新したい”っていう気持ちにすごくフィットしてるわ。」
ゆっくり魔理沙 「そうだぜ。この章のポイントをまとめるとこうなる。」
- Turbo Framesは、画面の一部分だけをHTMLで差し替える仕組み - link_to に data-turbo-frame を付けると、特定フレームを更新できる - レスポンス側でも同じidの turbo_frame_tag が必要 - 一覧と詳細を同じ画面に並べるUIと相性が良い - モーダルの中身をフレームとして読み込む構成も作りやすい - _top を使うとフレームの外、つまりページ全体へ遷移できる - フレームを増やしすぎると責務が曖昧になり、保守しづらくなる - フレーム名は役割がわかる名前にするのが大事
練習問題
問1
Turbo Drive と Turbo Frames の違いを説明してください。
問2
次のリンクは、どのフレームを更新しようとしていますか。
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
問3
Turbo Framesで部分更新を成功させるために、レスポンス側で必要なことは何ですか。
問4
モーダル内のリンクをページ全体遷移にしたいとき、data-turbo-frame には何を指定しますか。
問5
Turbo Framesを使いすぎると、どのような問題が起きやすいですか。2つ以上挙げてください。
章末ミニコラム: Turbo Framesは“ちょうどいい部分更新”を作る道具
ゆっくり霊夢 「SPAみたいに全部クライアントで組まなくても、ここまでできるのはかなり驚きね。」
ゆっくり魔理沙 「そうだぜ。Turbo Framesの良さは、“必要なところだけSPAっぽくできる” ことなんだ。」
全部をクライアントレンダリングにすると、自由度は高いぶん複雑さも増えます。 一方でTurbo Framesは、こういう気持ちにちょうどよく応えてくれます。
- この部分だけ更新したい - このフォームだけ差し替えたい - 一覧は残したまま詳細だけ見たい - モーダルの中身だけサーバーから取りたい
つまりFramesは、“ページ全体遷移”と“完全SPA”のちょうど中間 にある道具です。
ゆっくり魔理沙 「Hotwireの気持ちよさが一番わかりやすく出るのが、このFramesかもしれないな。」
Chapter 6: Turbo Streamsでリアルタイム更新
はじめに
ゆっくり霊夢 「Turbo Driveでページ遷移が速くなって、Turbo Framesで部分更新もできるようになったわ。 でも“リアルタイム更新”って、また別の話なの?」
ゆっくり魔理沙 「そうだぜ。ここで出てくる Turbo Streams は、ページやフレームを表示している最中に、HTMLの断片を差し込んだり置き換えたり削除したりする仕組み なんだ。」
ゆっくり霊夢 「おお、だんだん“ライブ感”が出てきたわね。」
ゆっくり魔理沙 「そうだな。しかも面白いのは、ここでもまだ主役はHTMLだ。 クライアント側でDOMをゴリゴリ組み立てるんじゃなくて、サーバーが返したHTMLでDOM操作を指示する のがTurbo Streamsなんだぜ。」
この章では、次の4つを扱います。
- 6.1 Turbo Streamsの基本
- 6.2 create/update/destroyの自動反映
- 6.3 複数ユーザーでの同期
- 6.4 ActionCableとの連携
6.1 Turbo Streamsの基本
6.1.1 Turbo Streamsとは何か
ゆっくり霊夢 「まず、Turbo StreamsってFramesとどう違うの?」
ゆっくり魔理沙 「いい質問だぜ。ざっくり言うとこうだ。」
Turbo Frames: - ある1つのフレーム領域を、丸ごと差し替える Turbo Streams: - DOMに対して append / prepend / replace / update / remove などの操作を行う
つまり、Framesは「箱ごと差し替える」感じで、Streamsは「箱の中身に対して細かい命令を出す」感じです。
6.1.2 まずは append の最小例を見る
たとえば、タスク一覧の末尾に新しいタスクを追加したいとします。
一覧画面をこうしておきます。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
<p>
<%= link_to "New task", new_task_path %>
</p>
タスク表示用パーシャルです。
<!-- app/views/tasks/_task_card.html.erb --> <section id="<%= dom_id(task) %>" class="task-card"> <h2><%= link_to task.title, task_path(task) %></h2> <p><strong>Status:</strong> <%= task.status %></p> <p><strong>Due:</strong> <%= task.due_on %></p> </section>
そして create.turbo_stream.erb を用意します。
<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.append "tasks", partial: "tasks/task_card", locals: { task: @task } %>
これで、id="tasks" の要素の末尾に、新しいタスクHTMLが追加されます。
ゆっくり霊夢
「えっ、JavaScriptで appendChild とか書かなくていいの?」
ゆっくり魔理沙 「そうだぜ。 それをRailsのビューで書けるのがTurbo Streamsの気持ちいいところなんだ。」
6.1.3 Turbo StreamがやっていることをHTMLで見る
Railsのヘルパーを使うと見えにくいので、実際のイメージも見ておきましょう。
turbo_stream.append "tasks", ... は、概念的にはこういうレスポンスに近いです。
<turbo-stream action="append" target="tasks"> <template> <section id="task_123" class="task-card"> <h2>Learn Turbo Streams</h2> <p><strong>Status:</strong> todo</p> <p><strong>Due:</strong> 2026-04-15</p> </section> </template> </turbo-stream>
ブラウザはこの <turbo-stream> を見ると、
target="tasks" の要素を探す ↓ template の中身を action に従って反映する
ということをやります。
6.1.4 よく使う action を整理する
Turbo Streamsでよく使う操作は次のあたりです。
append : 子要素として末尾に追加 prepend : 子要素として先頭に追加 replace : 対象要素そのものを置き換える update : 対象要素の中身だけを入れ替える remove : 対象要素を削除する before : 対象要素の直前に挿入 after : 対象要素の直後に挿入
Railsではこう書けます。
<%= turbo_stream.append "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.remove @task %>
ここで @task を渡すと、Railsは dom_id(@task) を使って対象idを解決してくれます。
6.1.5 create.turbo_stream.erb を有効にする条件
Turbo Streamsを使うときは、リクエストが turbo_stream 形式 で来る必要があります。 Hotwire環境では、Turbo対応フォームや特定の操作から自然にこの形式になることがあります。
コントローラでは respond_to を使う形がわかりやすいです。
# app/controllers/tasks_controller.rb def create @task = Task.new(task_params) respond_to do |format| if @task.save format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully created." } else format.html { render :new, status: :unprocessable_entity } end end end
このようにしておくと、
- Turbo Streamリクエストなら
create.turbo_stream.erb - 普通のHTMLリクエストならリダイレクト
という流れになります。
6.1.6 一番最初は「一覧に1件増える」から始める
ゆっくり霊夢 「Turbo Streamsってできることが多そうで、逆にどこから始めればいいかわからなくなりそう。」
ゆっくり魔理沙 「最初はこれで十分だぜ。」
1. 一覧に id="tasks" を付ける 2. 各行のパーシャルを作る 3. create.turbo_stream.erb で append する
この最小構成が理解できると、update や destroy もかなり自然に入ってきます。
6.2 create/update/destroyの自動反映
6.2.1 create を一覧へ自動反映する
まずは新規作成を、一覧へ即時反映するところからやります。
フォームは簡単のため index に埋め込んでしまいましょう。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<%= render "form", task: Task.new %>
<hr>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
フォームです。
<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
<% if task.errors.any? %>
<div style="color: red">
<ul>
<% task.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title %><br>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :description %><br>
<%= form.text_area :description %>
</div>
<div>
<%= form.label :status %><br>
<%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
</div>
<div>
<%= form.label :due_on %><br>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.submit "Create task" %>
</div>
<% end %>
コントローラです。
# app/controllers/tasks_controller.rb def index @tasks = Task.order(created_at: :desc) end def create @task = Task.new(task_params) respond_to do |format| if @task.save format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully created." } else format.html { render :index, status: :unprocessable_entity } end end end
create.turbo_stream.erb です。
<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
これで、作成した瞬間に一覧の先頭へタスクが追加されます。
6.2.2 作成後にフォームをリセットする
ゆっくり霊夢 「追加されたのはいいけど、フォームの中にさっきの値が残ってるとちょっと気になるわ。」
ゆっくり魔理沙 「そこもStreamsで解決できるぜ。 フォーム領域も一緒に差し替えればいい。」
まず、フォームをラップする領域を作ります。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<div id="task_form">
<%= render "form", task: Task.new %>
</div>
<hr>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
そして create.turbo_stream.erb をこうします。
<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace "task_form", partial: "tasks/form", locals: { task: Task.new } %>
これで、
- 一覧に新しいタスクを追加
- フォームを空の状態に戻す
を同時に行えます。
ゆっくり霊夢 「1回のレスポンスで2か所更新できるの、けっこう強いわね。」
ゆっくり魔理沙 「そこがStreamsの実務的においしいところだぜ。」
6.2.3 update をその場で反映する
次に、更新したらそのタスクの表示だけ差し替えてみましょう。
まず task_card に編集リンクを置きます。
<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
<h2><%= task.title %></h2>
<p><strong>Status:</strong> <%= task.status %></p>
<p><strong>Due:</strong> <%= task.due_on %></p>
<p>
<%= link_to "Edit", edit_task_path(task) %>
</p>
</section>
更新処理をこうします。
# app/controllers/tasks_controller.rb def update respond_to do |format| if @task.update(task_params) format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully updated." } else format.html { render :edit, status: :unprocessable_entity } end end end
update.turbo_stream.erb を作ります。
<!-- app/views/tasks/update.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
これで、そのタスクのDOM要素だけが置き換わります。
6.2.4 editフォームをインライン化する考え方
更新時にもっとHotwireらしくやるなら、表示行そのものをフォームに差し替え、保存後にまた表示へ戻す構成がよくあります。
たとえば編集用パーシャルを作ります。
<!-- app/views/tasks/_edit_form.html.erb -->
<section id="<%= dom_id(task) %>">
<%= form_with(model: task) do |form| %>
<div>
<%= form.text_field :title %>
</div>
<div>
<%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
</div>
<div>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.submit "Save" %>
</div>
<% end %>
</section>
edit.turbo_stream.erb を使って、その場をフォームにすることもできます。
<!-- app/views/tasks/edit.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/edit_form", locals: { task: @task } %>
これはかなりHotwireっぽい実装です。
6.2.5 destroy を即時反映する
削除はとてもわかりやすいです。
パーシャルに削除ボタンを追加します。
<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
<h2><%= task.title %></h2>
<p><strong>Status:</strong> <%= task.status %></p>
<p><strong>Due:</strong> <%= task.due_on %></p>
<p>
<%= button_to "Delete", task_path(task), method: :delete %>
</p>
</section>
コントローラです。
# app/controllers/tasks_controller.rb def destroy @task.destroy respond_to do |format| format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully destroyed." } end end
ビューです。
<!-- app/views/tasks/destroy.turbo_stream.erb --> <%= turbo_stream.remove @task %>
これで、削除後に一覧からその要素がスッと消えます。
ゆっくり霊夢 「これはかなり気持ちいいわ。 “ページ再読み込みして減ってる”じゃなくて、その場で消えるのね。」
ゆっくり魔理沙 「そうだぜ。 Streamsはこの“その場感”が強い。」
6.2.6 create/update/destroy を一覧化して整理する
ここまでの基本パターンを整理するとこうです。
create: - prepend / append で一覧へ新規追加 - 必要ならフォームも replace update: - replace で対象行を差し替え destroy: - remove で対象行を削除
コードの形としてはこういう対応になります。
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.remove @task %>
この3つがまず基本セットです。
6.3 複数ユーザーでの同期
6.3.1 1人だけの部分更新では終わらない
ゆっくり霊夢 「ここまでだと、自分の画面だけが更新される感じよね。」
ゆっくり魔理沙 「その通りだぜ。でもTurbo Streamsの本番の強さはここからだ。 他のユーザーが行った更新を、今見ている全員の画面へ反映できる。」
たとえば同じタスク一覧を、2つのブラウザで開いているとします。
ブラウザA: /tasks ブラウザB: /tasks
ここでAが新しいタスクを追加したら、Bの一覧にも自動で出てきたら気持ちいいですよね。 これを実現するのが、Turbo Streams + broadcasting です。
6.3.2 まずは購読を仕込む
一覧画面でストリームを購読します。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<%= turbo_stream_from "tasks" %>
<div id="task_form">
<%= render "form", task: Task.new %>
</div>
<hr>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
この turbo_stream_from "tasks" が重要です。
これで、このページは "tasks" というストリームを受け取る準備をします。
ゆっくり霊夢 「これだけで購読になるの?」
ゆっくり魔理沙 「そうだぜ。 あとはサーバー側から、そのストリームに向けて更新を流せばいい。」
6.3.3 モデルから broadcast する
最初はモデルのコールバックでやるのがわかりやすいです。
# app/models/task.rb class Task < ApplicationRecord STATUSES = %w[todo doing done].freeze validates :title, presence: true validates :status, presence: true, inclusion: { in: STATUSES } after_create_commit -> { broadcast_prepend_to "tasks" } after_update_commit -> { broadcast_replace_to "tasks" } after_destroy_commit -> { broadcast_remove_to "tasks" } end
これで、
- 作成時は
"tasks"ストリームへ prepend - 更新時は replace
- 削除時は remove
が自動で飛びます。
6.3.4 broadcast時にどのHTMLが使われるのか
この broadcast は、基本的には対象レコードのパーシャルを使います。
つまり Task なら _task.html.erb か、必要に応じて明示した partial が使われます。
今回一覧用に _task_card.html.erb を使いたいなら、明示しておくとわかりやすいです。
# app/models/task.rb after_create_commit -> { broadcast_prepend_to "tasks", target: "tasks", partial: "tasks/task_card", locals: { task: self } } after_update_commit -> { broadcast_replace_to "tasks", partial: "tasks/task_card", locals: { task: self } } after_destroy_commit -> { broadcast_remove_to "tasks" }
target: "tasks" は prepend先の親要素です。
6.3.5 2つのブラウザで試す
これで、2つのブラウザタブや2つのブラウザウィンドウで /tasks を開いて試せます。
ブラウザA
/tasks を開く
ブラウザB
/tasks を開く
Aでタスク作成
Title: Sync test Status: todo
するとB側にも、自動で新しいタスクが追加されます。
ゆっくり霊夢 「おお……これはかなり“リアルタイム感”あるわね。」
ゆっくり魔理沙 「そうだぜ。 しかも自分でWebSocketメッセージのJSONを組み立てたりしてないのが偉い。」
6.3.6 broadcastingと自分自身への更新
ここで少し実務的な注意です。
フォーム送信直後の自分の画面に対しては、
- コントローラの
create.turbo_stream.erb - モデルの
after_create_commitbroadcast
の両方が効くと、二重反映になることがあります。
つまり、こういうことです。
自分のリクエストに対するTurbo Streamレスポンス + broadcastで届くTurbo Stream = 同じ行が2回追加される可能性
これを避けるには設計方針をそろえます。
方針A: 自分向けは controller、他人向けは broadcast
方針B: 一律 broadcast に寄せる
学習段階では、まずは役割を分けて考えるのがおすすめです。
6.3.7 実務では「誰にどの更新を見せるか」を考える
この章では全員に "tasks" を配信していますが、実務ではもっと粒度を細かくすることがあります。
- プロジェクトごとのタスク一覧 - ログインユーザーごとの通知 - チームごとの更新
その場合、ストリーム名も工夫します。
<%= turbo_stream_from [@project, "tasks"] %>
モデル側も対応させます。
broadcast_prepend_to [project, "tasks"], target: "tasks"
こうすると、特定プロジェクトに属する人だけが更新を受け取る設計にできます。
6.4 ActionCableとの連携
6.4.1 Turbo Streamsのリアルタイム配信は何で動いているのか
ゆっくり霊夢 「さっきから“ストリームを購読”とか“broadcast”とか言ってるけど、裏では何が動いてるの?」
ゆっくり魔理沙 「そこで出てくるのが Action Cable だぜ。 Rails標準のWebSocket機能で、Turbo Streamsのリアルタイム配信を支えている。」
ざっくりした流れはこうです。
ブラウザが turbo_stream_from で購読する ↓ Action Cable がWebSocket接続を維持する ↓ サーバー側が broadcast_* を呼ぶ ↓ 購読中のブラウザへ Turbo Stream メッセージが届く ↓ DOMが更新される
つまり、Turbo Streamsのリアルタイム配信部分は、Action Cableの上に乗っているわけです。
6.4.2 Action Cableのルーティングを確認する
Railsでは通常、Action Cableのマウントが設定されています。
# config/routes.rb Rails.application.routes.draw do mount ActionCable.server => "/cable" resources :tasks root "tasks#index" end
これにより、ブラウザは /cable に対してWebSocket接続を張ります。
6.4.3 import側も確認する
turbo-rails を読み込んでいると、このあたりもいい感じに連携されます。
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers"
Railsの標準構成では、Action Cable用コードも含めてHotwire側がうまく配線してくれます。 なので最初は、Cableの低レベルAPIを直接触らなくてもよいことが多いです。
6.4.4 開発環境で動かすときの確認ポイント
リアルタイム同期が効かないときは、まず次を見ます。
- ブラウザの開発者ツールでWebSocket接続ができているか - /cable へ接続しているか - ページに turbo_stream_from があるか - モデルやコントローラで broadcast_* が呼ばれているか - 開発ログに Action Cable 関連ログが出ているか
たとえば Rails のログにこういう雰囲気のものが出ます。
Started GET "/cable" [WebSocket] Successfully upgraded to WebSocket Registered connection TasksChannel is streaming from tasks
実際の表記は環境で少し違いますが、WebSocket接続できているか が第一関門です。
6.4.5 まずは「専用Channelを書かなくても動く」を理解する
ゆっくり霊夢
「Action Cableって聞くと、TasksChannel みたいなのを自分で書かないといけないイメージがあるわ。」
ゆっくり魔理沙 「昔ながらのCable入門だとそういう印象があるな。でもTurbo Streamsでは、最初はそこまでやらなくていいことが多い。」
turbo_stream_from と broadcast_* を使うと、かなりの範囲をRails側が面倒見てくれます。
だから学習の最初は、
Action Cableの概念を知る ↓ でもまずはTurbo StreamsのAPIを使う
で十分です。
6.4.6 それでもAction Cableを意識したほうがいい理由
とはいえ、完全に忘れてよいわけではありません。 実務では次のような観点でAction Cableの存在を意識します。
- 本番でWebSocketがちゃんと通る構成か - 複数プロセス・複数サーバーでどう中継するか - Redisなどのpub/subバックエンドをどう使うか - 誰に何を配信するかの認可をどう考えるか
学習本の段階では深入りしすぎなくてOKですが、 「Turbo StreamsのリアルタイムはAction Cableの上にある」という土台は押さえておくと後で強いです。
6.4.7 controller主導とmodel主導を使い分ける
最後に、実装スタイルの整理もしておきます。
controller主導
def create @task = Task.new(task_params) if @task.save respond_to do |format| format.turbo_stream format.html { redirect_to tasks_path } end end end
model主導
after_create_commit -> { broadcast_prepend_to "tasks" }
違いをざっくり言うとこうです。
controller主導: - このリクエストに対して何を返すかが見やすい - 1ユーザー向けの更新を作りやすい model主導: - 永続化イベントに応じて配信できる - 複数ユーザー同期と相性が良い
ゆっくり霊夢 「使い分けが大事なのね。」
ゆっくり魔理沙 「そうだぜ。 最初はcontrollerで理解して、リアルタイム同期ではmodelのbroadcastも使う、くらいの順番が自然だ。」
この章のまとめ
ゆっくり霊夢 「Turbo Streams、かなり面白いわね。 HTMLでDOM操作を指示するっていう発想が、やっと腹落ちしてきた感じ。」
ゆっくり魔理沙 「その感覚が大事だぜ。この章のポイントをまとめるとこうなる。」
- Turbo Streamsは、append / replace / remove などでDOM更新を行う仕組み - サーバーは turbo-stream レスポンスを返し、ブラウザがその命令を適用する - create では prepend/append、update では replace、destroy では remove が基本 - 1つのレスポンスで複数箇所を更新できる - turbo_stream_from でストリームを購読できる - broadcast_* を使うと、複数ユーザーへ同じ更新を配信できる - リアルタイム配信の土台には Action Cable がある - controller主導の更新と model主導のbroadcast は役割を分けて考えるとわかりやすい
練習問題
問1
Turbo Frames と Turbo Streams の違いを説明してください。
問2
一覧 id="tasks" の先頭に新しいタスクを追加したいとき、どの helper を使うのが自然ですか。
問3
次のコードは何をしているでしょうか。
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
問4
複数ユーザーの画面へ同じ更新を配信するために、ビュー側で書く購読コードは何ですか。
問5
Turbo Streamsのリアルタイム配信を支えているRails標準機能は何ですか。
章末ミニコラム: Turbo Streamsは“DOM操作をサーバーへ戻す”感覚が大事
ゆっくり霊夢 「今までだと、“追加・更新・削除のたびにJavaScriptでDOMをいじる”って考えが普通だった気がするわ。」
ゆっくり魔理沙 「そこがHotwireの面白い逆転なんだぜ。 Turbo Streamsでは、“DOM操作の意図”をサーバー側で表現するんだ。」
たとえば従来の発想はこうです。
JSONを受け取る ↓ JavaScriptでDOMノードを組み立てる ↓ appendする
Turbo Streamsではこうです。
サーバーがHTML断片を作る ↓ append / replace / remove の命令と一緒に返す ↓ ブラウザが適用する
つまり、UI更新の責務をもう一度サーバー側テンプレートへ寄せている わけです。 Railsアプリでは、この考え方がかなりしっくり来る場面が多いです。
ゆっくり魔理沙 「Turbo Streamsを理解すると、“Reactなしでもかなりいける” の実感が一気に強くなるんだぜ。」
Chapter 7: Stimulusでフロント制御
はじめに
ゆっくり霊夢 「Turbo DriveとTurbo FramesとTurbo Streamsまで来たけど、これで全部できちゃう気がしてきたわ。」
ゆっくり魔理沙 「気持ちはわかるが、それだけだと“細かいUIの動き”が足りない場面が出てくる。 そこで出てくるのが Stimulus だぜ。」
ゆっくり霊夢 「つまり、ちょっとしたJavaScriptを足す役?」
ゆっくり魔理沙 「その通り。 Hotwireでは、大きな更新はTurbo、小さな振る舞いはStimulus で分担するのが基本なんだ。」
この章では、次の4つを扱います。
- 7.1 Stimulusの思想
- 7.2 Controllerの基本
- 7.3 DOM操作とイベント
- 7.4 Turboとの共存パターン
7.1 Stimulusの思想
7.1.1 Stimulusは“控えめなJavaScript”
ゆっくり霊夢 「ReactとかVueみたいなフレームワークとは違うの?」
ゆっくり魔理沙 「かなり違うぜ。 Stimulusは、“画面全体を支配する”んじゃなくて、“HTMLにちょい足しする”ためのものだ。」
比較するとこうです。
React / Vue: - UIをJSで構築する - 状態管理が中心 - 仮想DOMなどが主役 Stimulus: - 既存のHTMLに振る舞いを追加する - サーバーHTMLが主役 - DOMに直接紐づく
7.1.2 HTMLファーストという考え方
Hotwire全体の思想はこうです。
1. HTMLをサーバーで作る 2. Turboで更新する 3. 足りないところだけStimulusで補う
つまりStimulusは、
“JSで全部作る” のではなく “HTMLに意味と行動を付ける”
という発想です。
7.1.3 データ属性で振る舞いを定義する
Stimulusの特徴は、HTML側に振る舞いを書くことです。
<div data-controller="hello"> <button data-action="click->hello#greet">Click me</button> </div>
JavaScriptはこうです。
// app/javascript/controllers/hello_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { greet() { alert("Hello Stimulus!") } }
ゆっくり霊夢 「HTML側に“どのJSを呼ぶか”が書いてあるのね。」
ゆっくり魔理沙 「そうだぜ。 これで“どこで何が起きるか”がコードを跨がずに読める。」
7.1.4 Stimulusの役割まとめ
- UIのちょっとした動きを担当 - HTMLと密接に結びつく - Turboではできない細かい制御を補う - 小さく分割するのが前提
7.2 Controllerの基本
7.2.1 Controllerを作る
まずは簡単なControllerを作ります。
// app/javascript/controllers/counter_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["output"] increment() { const current = Number(this.outputTarget.textContent) this.outputTarget.textContent = current + 1 } }
HTML側です。
<div data-controller="counter">
<p data-counter-target="output">0</p>
<button data-action="click->counter#increment">
+1
</button>
</div>
7.2.2 Controllerの構造
基本形はこうです。
export default class extends Controller { static targets = ["name"] connect() { // 初期化 } action() { // イベント処理 } }
重要な要素は3つです。
controller: data-controller targets : data-xxx-target actions : data-action
7.2.3 connect() の役割
connect() { console.log("connected") }
これは、ControllerがDOMに接続されたときに呼ばれます。
Turbo環境では、
ページ遷移 ↓ turbo:load ↓ Stimulus connect
という流れで呼ばれます。
7.2.4 targetsの使い方
static targets = ["input", "output"]
HTML:
<input data-counter-target="input"> <p data-counter-target="output"></p>
JS:
this.inputTarget.value this.outputTarget.textContent
7.2.5 複数targets
static targets = ["item"]
HTML:
<li data-controller="list"> <span data-list-target="item">A</span> <span data-list-target="item">B</span> </li>
JS:
this.itemTargets.forEach((el) => { console.log(el.textContent) })
7.2.6 valuesで状態を持つ
static values = { count: Number } increment() { this.countValue++ }
HTML:
<div data-controller="counter" data-counter-count-value="0">
これは状態管理の軽量版です。
7.2.7 classesでスタイル制御
static classes = ["active"] toggle() { this.element.classList.toggle(this.activeClass) }
7.3 DOM操作とイベント
7.3.1 入力文字数カウント
// app/javascript/controllers/text_counter_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["input", "output"] update() { this.outputTarget.textContent = this.inputTarget.value.length } }
<div data-controller="text-counter">
<textarea data-text-counter-target="input"
data-action="input->text-counter#update"></textarea>
<p>
文字数: <span data-text-counter-target="output">0</span>
</p>
</div>
7.3.2 トグルUI
// toggle_controller.js export default class extends Controller { static targets = ["content"] toggle() { this.contentTarget.hidden = !this.contentTarget.hidden } }
<div data-controller="toggle"> <button data-action="click->toggle#toggle">Toggle</button> <div data-toggle-target="content" hidden> Hidden content </div> </div>
7.3.3 CSSクラス切り替え
toggle() { this.element.classList.toggle("is-active") }
7.3.4 イベントの種類
data-action=" click->controller#method input->controller#method submit->controller#method "
7.3.5 preventDefault
submit(event) { event.preventDefault() }
7.3.6 debounce的な処理
update() { clearTimeout(this.timeout) this.timeout = setTimeout(() => { console.log("debounced") }, 300) }
7.3.7 DOM操作まとめ
- textContent - value - classList - hidden - insertAdjacentHTML
7.4 Turboとの共存パターン
7.4.1 Turbo + Stimulusの役割分担
ゆっくり霊夢 「ここまででだいぶ理解できたけど、Turboとどう使い分けるのが正解なの?」
ゆっくり魔理沙 「鉄板の考え方はこれだぜ。」
Turbo: - データ更新 - HTML差し替え Stimulus: - UIの細かい動き - 即時反応
7.4.2 Turboで追加 + Stimulusで動き
例: 新しいタスク追加時にハイライト
// highlight_controller.js export default class extends Controller { connect() { this.element.classList.add("highlight") setTimeout(() => { this.element.classList.remove("highlight") }, 1000) } }
<section id="<%= dom_id(task) %>"
data-controller="highlight">
7.4.3 Turbo更新後にStimulusが再接続される
Turbo StreamでHTMLが追加 ↓ DOMに挿入 ↓ Stimulus connect() が呼ばれる
これが重要なポイントです。
7.4.4 フォーム + Stimulus + Turbo
// disable_submit_controller.js export default class extends Controller { static targets = ["button"] disable() { this.buttonTarget.disabled = true } }
<form data-controller="disable-submit"
data-action="submit->disable-submit#disable">
<button data-disable-submit-target="button">
Submit
</button>
</form>
7.4.5 Turboイベントを使う
document.addEventListener("turbo:load", () => { console.log("page loaded") })
7.4.6 よくあるアンチパターン
❌ StimulusでDOMを作り込みすぎる ❌ Turboを無視してfetchを書く ❌ controllerを巨大化させる
7.4.7 正しい使い方の感覚
データ更新 → Turbo UI演出 → Stimulus
この章のまとめ
ゆっくり霊夢 「Stimulusって“地味だけどめちゃ重要”って感じね。」
ゆっくり魔理沙 「その通りだぜ。」
- StimulusはHTMLに振る舞いを追加する軽量JS - controller / target / action の3つが基本 - DOM操作はシンプルに保つ - Turboと組み合わせることで真価を発揮する
練習問題
問1
StimulusとReactの違いは何ですか?
問2
次のコードでクリック時に呼ばれるメソッドは何ですか?
<button data-action="click->counter#increment">
問3
targets の役割を説明してください。
問4
TurboとStimulusの役割分担は?
問5
connect() はいつ呼ばれますか?
章末ミニコラム: Stimulusは“足りない分だけ書く”が正解
ゆっくり霊夢 「全部JSでやるより、かなり楽な気がしてきたわ。」
ゆっくり魔理沙 「それが狙いだぜ。」
JSは最小限 HTMLが主役
この感覚が身につくと、
👉 「Reactいらなくね?」 ではなく 👉 「Reactが必要な場所だけ使えばいい」
という設計ができるようになります。
Chapter 8: UIをリッチにする
はじめに
ゆっくり霊夢 「ここまでで、CRUDもできたし、Turbo DriveもFramesもStreamsもStimulusも出てきたわ。 でも“実務っぽい触り心地”という意味では、まだ少し素朴な感じもあるわね。」
ゆっくり魔理沙 「そうだぜ。ここからは、小さなUX改善を積み上げて“使いやすいアプリ”にしていく章だ。 しかもHotwireは、こういう“ちょっといい体験”を作るのがかなり得意なんだ。」
ゆっくり霊夢 「派手すぎるSPAじゃなくても、かなり気持ちよくできるってやつね。」
ゆっくり魔理沙 「その通りだぜ。 この章では、見た目よりも“操作感”を上げる実践パターンをやっていこう。」
この章では、次の4つを扱います。
- 8.1 インライン編集(edit in place)
- 8.2 ドラッグ&ドロップ(並び替え)
- 8.3 ローディング表示
- 8.4 フォームバリデーション改善
8.1 インライン編集(edit in place)
8.1.1 インライン編集とは何か
ゆっくり霊夢 「インライン編集って、一覧のその場で編集できるやつよね?」
ゆっくり魔理沙 「そうだぜ。 “詳細画面へ移動して編集” ではなく、表示中の行そのものをフォームに差し替える UXだ。」
イメージはこうです。
通常表示: [ Learn Hotwire ] [Edit] Editを押す ↓ その場でフォームに変化 [ title: Learn Hotwire ] [ status: todo ] [ Save ] [Cancel]
このパターンは、Turbo Frames と Turbo Streams と Stimulus の相性がとても良いです。
8.1.2 まずは表示用パーシャルを用意する
まず、通常表示用のパーシャルを確認します。
<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
<h2><%= task.title %></h2>
<p>
<strong>Status:</strong>
<%= task.status %>
</p>
<p>
<strong>Due:</strong>
<%= task.due_on %>
</p>
<p>
<%= link_to "Edit", edit_task_path(task), data: { turbo_stream: true } %>
<%= button_to "Delete", task_path(task), method: :delete %>
</p>
</section>
ここでは Edit を Turbo Stream リクエストとして扱う想定にしています。
8.1.3 編集用パーシャルを用意する
次に、同じ場所へ差し込む編集フォームを作ります。
<!-- app/views/tasks/_edit_form.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card task-card--editing">
<%= form_with(model: task) do |form| %>
<% if task.errors.any? %>
<div class="form-errors">
<ul>
<% task.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title %><br>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :status %><br>
<%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
</div>
<div>
<%= form.label :due_on %><br>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.submit "Save" %>
<%= link_to "Cancel", task_path(task), data: { turbo_stream: true } %>
</div>
<% end %>
</section>
8.1.4 editアクションをTurbo Stream対応にする
edit で、その行を編集フォームに差し替えます。
# app/controllers/tasks_controller.rb def edit respond_to do |format| format.turbo_stream format.html end end
edit.turbo_stream.erb を作ります。
<!-- app/views/tasks/edit.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/edit_form", locals: { task: @task } %>
これで Edit を押すと、そのタスク行がフォームに置き換わります。
ゆっくり霊夢 「これかなりいいわね。 ページ遷移しないのに、ちゃんと編集に入った感じがある。」
ゆっくり魔理沙 「そうだぜ。 “その場で編集できる” のは業務アプリだとかなり効く。」
8.1.5 update後に表示へ戻す
更新後は、再び通常表示へ戻します。
# app/controllers/tasks_controller.rb def update respond_to do |format| if @task.update(task_params) format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully updated." } else format.turbo_stream { render :edit, status: :unprocessable_entity } format.html { render :edit, status: :unprocessable_entity } end end end
update.turbo_stream.erb はこうです。
<!-- app/views/tasks/update.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
失敗時は edit.turbo_stream.erb がそのまま使えるようにしておくとわかりやすいです。
8.1.6 Cancelで表示へ戻す
Cancelも Turbo Stream で通常表示へ戻せます。
show を stream 対応してもいいですが、軽く済ませるなら show.turbo_stream.erb を用意します。
# app/controllers/tasks_controller.rb def show respond_to do |format| format.html format.turbo_stream end end
<!-- app/views/tasks/show.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
これで Cancel を押すと、フォームが通常表示に戻ります。
8.1.7 フォーカスを自動で当てる
インライン編集では、フォームに切り替わったら最初の入力欄へフォーカスがあると気持ちいいです。 ここで Stimulus を使います。
// app/javascript/controllers/autofocus_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.focus() this.element.select?.() } }
フォーム側で使います。
<%= form.text_field :title, data: { controller: "autofocus" } %>
ゆっくり霊夢 「こういう小さい気配り、使い心地がかなり変わるわね。」
ゆっくり魔理沙 「そうだぜ。 HotwireのUI改善って、こういう積み重ねが強い。」
8.1.8 インライン編集の設計ポイント
インライン編集の基本パターンを整理するとこうです。
通常表示パーシャル ↓ Edit edit.turbo_stream.erb で編集フォームへ replace ↓ Save update.turbo_stream.erb で通常表示へ replace ↓ Cancel show.turbo_stream.erb で通常表示へ replace
つまり、同じDOM領域を表示用と編集用で入れ替える設計です。
8.2 ドラッグ&ドロップ(並び替え)
8.2.1 並び替えUIの考え方
ゆっくり霊夢 「次はドラッグ&ドロップか。 これはちょっとJS強そうね。」
ゆっくり魔理沙 「確かにここはStimulusの出番が大きい。 でもサーバー保存まで含めて考えると、Hotwireと相性は悪くないぜ。」
まず、Task に並び順カラムを追加します。
bin/rails generate migration AddPositionToTasks position:integer bin/rails db:migrate
必要なら初期値も入れます。
# db/migrate/xxxxxxxxxxxxxx_add_position_to_tasks.rb class AddPositionToTasks < ActiveRecord::Migration[7.0] def change add_column :tasks, :position, :integer end end
モデル側では並び順を使います。
# app/models/task.rb class Task < ApplicationRecord default_scope { order(position: :asc, created_at: :asc) } end
8.2.2 ルーティングを追加する
並び順更新用のルートを作ります。
# config/routes.rb Rails.application.routes.draw do resources :tasks do patch :reorder, on: :collection end root "tasks#index" end
8.2.3 一覧に data 属性を付ける
並び替え対象の一覧を Stimulus controller に紐づけます。
<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>
<ul
id="tasks"
data-controller="sortable"
data-sortable-url-value="<%= reorder_tasks_path %>"
>
<% @tasks.each do |task| %>
<li
id="<%= dom_id(task) %>"
data-sortable-target="item"
data-task-id="<%= task.id %>"
draggable="true"
>
<%= render "task_card", task: task %>
</li>
<% end %>
</ul>
8.2.4 Stimulusでドラッグ操作を扱う
まずはHTML5 DnDを使う簡易版です。
// app/javascript/controllers/sortable_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["item"] static values = { url: String } connect() { this.draggedItem = null this.itemTargets.forEach((item) => { item.addEventListener("dragstart", this.handleDragStart) item.addEventListener("dragover", this.handleDragOver) item.addEventListener("drop", this.handleDrop) item.addEventListener("dragend", this.handleDragEnd) }) } disconnect() { this.itemTargets.forEach((item) => { item.removeEventListener("dragstart", this.handleDragStart) item.removeEventListener("dragover", this.handleDragOver) item.removeEventListener("drop", this.handleDrop) item.removeEventListener("dragend", this.handleDragEnd) }) } handleDragStart = (event) => { this.draggedItem = event.currentTarget event.dataTransfer.effectAllowed = "move" } handleDragOver = (event) => { event.preventDefault() event.dataTransfer.dropEffect = "move" } handleDrop = (event) => { event.preventDefault() const dropTarget = event.currentTarget if (!this.draggedItem || this.draggedItem === dropTarget) return const rect = dropTarget.getBoundingClientRect() const offset = event.clientY - rect.top const middle = rect.height / 2 if (offset < middle) { dropTarget.parentNode.insertBefore(this.draggedItem, dropTarget) } else { dropTarget.parentNode.insertBefore(this.draggedItem, dropTarget.nextSibling) } this.saveOrder() } handleDragEnd = () => { this.draggedItem = null } saveOrder() { const ids = this.itemTargets.map((item) => item.dataset.taskId) fetch(this.urlValue, { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content, "Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml" }, body: JSON.stringify({ task_ids: ids }) }) } }
8.2.5 サーバー側で順序を保存する
コントローラに reorder を追加します。
# app/controllers/tasks_controller.rb def reorder params[:task_ids].each_with_index do |id, index| Task.where(id: id).update_all(position: index + 1) end head :ok end
まずはこれで十分です。
ゆっくり霊夢 「ここはTurbo Stream返してないのね。」
ゆっくり魔理沙 「最初の一歩としては、まずDOM順とDB順を揃えることが大事だぜ。 必要なら後で“他ユーザーへ順序変更を配信”にも育てられる。」
8.2.6 並び替え後に見た目を少し整える
ドラッグ中の見た目を改善します。
/* app/assets/stylesheets/application.css */ [draggable="true"] { cursor: grab; } [draggable="true"]:active { cursor: grabbing; } .task-card { padding: 12px; border: 1px solid #ddd; margin-bottom: 8px; background: #fff; }
8.2.7 並び替えの実務的注意点
この実装は学習用としては良いですが、実務では次も考えます。
- 他人が同時に並び替えたらどうするか - 権限のある人だけ変更できるか - drag & drop 操作のアクセシビリティ - 大量件数でのパフォーマンス
でもHotwire本の文脈では、まず
Stimulusで操作 ↓ サーバーへ保存 ↓ 必要ならTurbo Streamで他画面へ反映
という流れが理解できれば十分です。
8.3 ローディング表示
8.3.1 ローディングは“安心感”を作る
ゆっくり霊夢 「操作が速くても、何も反応がないと逆に不安になることあるのよね。」
ゆっくり魔理沙 「そうだぜ。 ローディング表示は“速度の問題”というより、“安心して操作できるか”の問題なんだ。」
8.3.2 ページ遷移時のローディング
Turbo Driveのイベントで簡単に出せます。
<!-- app/views/layouts/application.html.erb -->
<body>
<div id="loading-indicator" class="loading-indicator" hidden>
Loading...
</div>
<%= yield %>
</body>
/* app/assets/stylesheets/application.css */ .loading-indicator { position: fixed; top: 12px; right: 12px; padding: 8px 12px; background: #222; color: white; border-radius: 8px; z-index: 9999; }
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers" const indicator = () => document.getElementById("loading-indicator") document.addEventListener("turbo:before-visit", () => { indicator()?.removeAttribute("hidden") }) document.addEventListener("turbo:load", () => { indicator()?.setAttribute("hidden", true) })
8.3.3 フォーム送信中の表示
送信中にボタン文言を変えるのも定番です。 Stimulusでやるときれいです。
// app/javascript/controllers/submit_state_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["button", "spinner"] start() { this.buttonTarget.disabled = true this.buttonTarget.value = "Saving..." this.spinnerTarget.hidden = false } finish() { this.buttonTarget.disabled = false this.buttonTarget.value = "Save" this.spinnerTarget.hidden = true } }
フォーム側です。
<%= form_with(model: task,
data: {
controller: "submit-state",
action: "turbo:submit-start->submit-state#start turbo:submit-end->submit-state#finish"
}) do |form| %>
<div>
<%= form.text_field :title %>
</div>
<div>
<%= form.submit "Save", data: { "submit-state-target": "button" } %>
<span hidden data-submit-state-target="spinner">Saving...</span>
</div>
<% end %>
ゆっくり霊夢 「これすごくいいわ。 “押せてるのかな?”問題が減る。」
ゆっくり魔理沙 「そうだぜ。 しかも二重送信防止にもなる。」
8.3.4 フレーム単位のローディング
Turbo Frames を使っているなら、フレームの中だけ読み込み中表示を出したくなります。
たとえば詳細パネルをこうしておきます。
<%= turbo_frame_tag "task_details" do %> <div class="placeholder">Select a task.</div> <% end %>
そこにローディング用 Stimulus を付けます。
// app/javascript/controllers/frame_loading_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.addEventListener("turbo:before-fetch-request", this.show) this.element.addEventListener("turbo:frame-load", this.hide) } disconnect() { this.element.removeEventListener("turbo:before-fetch-request", this.show) this.element.removeEventListener("turbo:frame-load", this.hide) } show = () => { this.element.classList.add("is-loading") } hide = () => { this.element.classList.remove("is-loading") } }
HTML:
<%= turbo_frame_tag "task_details", data: { controller: "frame-loading" } do %>
<div class="placeholder">Select a task.</div>
<% end %>
CSS:
#task_details.is-loading { opacity: 0.6; }
8.3.5 過剰なローディングは逆効果
ローディング表示は便利ですが、やりすぎると逆にうるさくなります。
- 一瞬の処理に毎回大げさなスピナー - あちこち点滅する - 画面全体をすぐ覆ってしまう
おすすめはこうです。
- ページ全体遷移 → 小さい固定表示 - フォーム送信 → ボタン状態変更 - フレーム読み込み → 該当領域だけ薄くする
8.4 フォームバリデーション改善
8.4.1 Rails標準でも十分強い
ゆっくり霊夢 「バリデーション改善って、フロントで全部チェックする話?」
ゆっくり魔理沙 「いや、まずはRails標準をちゃんと活かすのが先だぜ。 サーバー側バリデーションは最終防衛線だからな。」
モデルはこうでした。
# app/models/task.rb class Task < ApplicationRecord STATUSES = %w[todo doing done].freeze validates :title, presence: true validates :status, presence: true, inclusion: { in: STATUSES } end
フォームではエラーを表示していました。
<% if task.errors.any? %>
<div class="form-errors">
<ul>
<% task.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
これだけでも十分大事です。
8.4.2 フィールド単位のエラー表示
全体エラーだけでなく、項目ごとのエラーが見えると使いやすいです。
<!-- app/views/tasks/_form.html.erb -->
<div class="field">
<%= form.label :title %><br>
<%= form.text_field :title, class: ("field-error" if task.errors[:title].any?) %>
<% task.errors[:title].each do |message| %>
<div class="error-message"><%= message %></div>
<% end %>
</div>
CSS:
.field-error { border: 1px solid #d33; background: #fff7f7; } .error-message { color: #c00; font-size: 0.9rem; margin-top: 4px; }
8.4.3 入力中に文字数を見せる
バリデーションそのものではないですが、入力補助としてかなり効きます。
// app/javascript/controllers/length_counter_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["input", "output"] static values = { max: Number } update() { const length = this.inputTarget.value.length this.outputTarget.textContent = `${length} / ${this.maxValue}` this.outputTarget.classList.toggle("over-limit", length > this.maxValue) } }
HTML:
<div
data-controller="length-counter"
data-length-counter-max-value="100"
>
<%= form.label :title %><br>
<%= form.text_field :title,
data: {
"length-counter-target": "input",
action: "input->length-counter#update"
} %>
<div data-length-counter-target="output">0 / 100</div>
</div>
CSS:
.over-limit { color: #c00; font-weight: bold; }
8.4.4 submit前の軽いチェック
重いロジックはサーバー側に残しつつ、明らかな未入力だけクライアントで補助するのはありです。
// app/javascript/controllers/simple_validation_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["title", "message"] validate(event) { if (this.titleTarget.value.trim() === "") { event.preventDefault() this.messageTarget.textContent = "Title is required." this.titleTarget.focus() } else { this.messageTarget.textContent = "" } } }
HTML:
<div data-controller="simple-validation">
<%= form_with(model: task, data: { action: "submit->simple-validation#validate" }) do |form| %>
<div>
<%= form.text_field :title, data: { "simple-validation-target": "title" } %>
</div>
<div class="error-message" data-simple-validation-target="message"></div>
<%= form.submit "Save" %>
<% end %>
</div>
ゆっくり霊夢 「でもこれで安心しちゃだめなのよね?」
ゆっくり魔理沙 「その通り。 クライアント検証は補助、最終判断はサーバーだぜ。」
8.4.5 エラー時に先頭へスクロールする
フォームが長いと、エラーが出ても気づきにくいことがあります。 これも Stimulus で補助できます。
// app/javascript/controllers/error_focus_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { const errorBox = this.element.querySelector(".form-errors, .error-message") if (errorBox) { errorBox.scrollIntoView({ behavior: "smooth", block: "center" }) } } }
フォームを包みます。
<div data-controller="error-focus"> <%= render "form", task: @task %> </div>
8.4.6 バリデーション改善の方針
ここでの考え方をまとめるとこうです。
サーバー側: - 真実のルールを持つ - 保存可否を決める クライアント側: - 入力補助 - 気づきやすさ改善 - ストレス軽減
つまり、厳密な判定をクライアントへ寄せすぎないことが大事です。
この章のまとめ
ゆっくり霊夢 「この章、かなり“実務で欲しいやつ”だったわね。 派手じゃないけど、使い心地が一段上がる感じがしたわ。」
ゆっくり魔理沙 「そうだぜ。 Hotwireは大規模SPAみたいな派手さより、細かいUX改善を素直に積める強さ があるんだ。」
この章のポイントをまとめるとこうです。
- インライン編集は、表示用パーシャルと編集用パーシャルの切り替えで実現しやすい - Turbo Stream の replace を使うと、その場編集が自然に作れる - ドラッグ&ドロップは Stimulus で操作し、サーバーへ順序保存する流れが基本 - ローディング表示は、ページ全体・フォーム送信・フレーム単位で出し分けるとよい - フォーム改善では、サーバー側バリデーションを中心にしつつ、Stimulusで入力補助を足す - Hotwireでは“全部JSで作る”より、“HTML中心のUIを少しずつ磨く”発想が大事
練習問題
問1
インライン編集を実装するとき、replace で入れ替える代表的な2種類のパーシャルは何ですか。
問2
ドラッグ&ドロップの並び替えで、最終的にサーバーへ保存したい情報は何ですか。
問3
フォーム送信中にボタンを Saving... に変えるのは、どのようなUX上の利点がありますか。
問4
クライアント側バリデーションとサーバー側バリデーションは、どう役割分担するのがよいですか。
問5
Turbo Frame の読み込み中だけ見た目を薄くするには、どのようなアプローチが考えられますか。
章末ミニコラム: “リッチUI”は大げさなフロントエンドだけのものではない
ゆっくり霊夢 「“リッチUI”って聞くと、つい巨大なJSアプリを想像しちゃうのよね。」
ゆっくり魔理沙 「それはわりとみんなそうだな。 でも実際には、使いやすさを決めるのって、もっと小さい体験の積み重ねなんだぜ。」
たとえばこの章でやったことは、どれもそこまで大げさではありません。
- その場で編集できる - 並び替えが直感的 - 送信中がわかる - エラーにすぐ気づける
でも、こういう改善が揃うと、アプリ全体の印象はかなり変わります。
ゆっくり魔理沙 「Hotwireの強みは、“必要十分なリッチさ” をRailsの延長で作れることなんだぜ。」
Chapter 9: 実務的な設計パターン
はじめに
ゆっくり霊夢 「ここまででHotwireの機能はかなり揃ったわね。 でも実務で使うなら、“動く”だけじゃなくて“壊れにくい”とか“読みやすい”も大事になってくるわよね。」
ゆっくり魔理沙 「その通りだぜ。 Hotwireは手軽に作れるぶん、油断するとビューが太る、コントローラが膨らむ、Streamの責務が散るみたいなことが起きやすい。」
ゆっくり霊夢 「つまりこの章は、“Hotwireを雑に書かないための章”ってことね。」
ゆっくり魔理沙 「そうだぜ。 この章では、実務で効いてくる設計パターンを見ていこう。」
この章では、次の4つを扱います。
- 9.1 ViewComponentとの組み合わせ
- 9.2 Form Object / Service Object
- 9.3 Turbo Streamsの設計指針
- 9.4 N+1とパフォーマンス
9.1 ViewComponentとの組み合わせ
9.1.1 Hotwireはビュー設計がかなり重要
ゆっくり霊夢 「HotwireってHTML中心だから、ビューがどんどん増えるわよね。」
ゆっくり魔理沙 「そうなんだぜ。 Turbo FramesもTurbo Streamsも、最終的には“どのHTMLを返すか”が核心だから、ビューの整理がそのまま保守性に直結する。」
たとえば、ここまでのアプリでもすでにこういうファイルがありました。
app/views/tasks/ index.html.erb show.html.erb new.html.erb edit.html.erb _form.html.erb _task_card.html.erb _edit_form.html.erb create.turbo_stream.erb update.turbo_stream.erb destroy.turbo_stream.erb
このくらいならまだ読めますが、実務で項目や分岐が増えると、ERBだけではつらくなりやすいです。
9.1.2 ViewComponentを使う動機
ViewComponentを使うと、表示単位をRubyオブジェクトとして切り出せるようになります。
たとえば、タスク一覧の1行表示をコンポーネント化したいとします。
イメージはこうです。
# app/components/task_card_component.rb class TaskCardComponent < ViewComponent::Base def initialize(task:) @task = task end private attr_reader :task end
テンプレートです。
<!-- app/components/task_card_component.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
<h2><%= task.title %></h2>
<p>
<strong>Status:</strong>
<%= task.status %>
</p>
<p>
<strong>Due:</strong>
<%= task.due_on %>
</p>
<p>
<%= link_to "Edit", edit_task_path(task), data: { turbo_stream: true } %>
<%= button_to "Delete", task_path(task), method: :delete %>
</p>
</section>
ヘルパーメソッドを用意してもいいですが、ここでは単純に task を reader で見せます。
# app/components/task_card_component.rb class TaskCardComponent < ViewComponent::Base def initialize(task:) @task = task end private attr_reader :task end
使う側です。
<!-- app/views/tasks/index.html.erb -->
<div id="tasks">
<% @tasks.each do |task| %>
<%= render(TaskCardComponent.new(task: task)) %>
<% end %>
</div>
9.1.3 partialとcomponentの違い
ゆっくり霊夢 「パーシャルがあるのに、なんでわざわざComponentにするの?」
ゆっくり魔理沙 「大きな違いは、表示ロジックをRubyとして閉じ込めやすいことだぜ。」
たとえば、表示用のメソッドが増えてきたとします。
# app/components/task_card_component.rb class TaskCardComponent < ViewComponent::Base def initialize(task:) @task = task end def status_label case task.status when "todo" "To Do" when "doing" "In Progress" when "done" "Done" else task.status end end def overdue? task.due_on.present? && task.due_on < Date.current && task.status != "done" end private attr_reader :task end
テンプレート側はすっきりします。
<!-- app/components/task_card_component.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card <%= "task-card--overdue" if overdue? %>">
<h2><%= task.title %></h2>
<p>
<strong>Status:</strong>
<%= status_label %>
</p>
<p>
<strong>Due:</strong>
<%= task.due_on %>
</p>
</section>
ERBの中に条件分岐を大量に書くより、だいぶ読みやすいです。
9.1.4 Turbo StreamsとComponentの相性
Turbo Streamsでもコンポーネントは使えます。
<!-- app/views/tasks/update.turbo_stream.erb --> <%= turbo_stream.replace @task do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %>
あるいは partial 指定ではなく block 形式を使うと、Streamの中身としてコンポーネントをそのまま描画できます。
createも同じです。
<!-- app/views/tasks/create.turbo_stream.erb --> <%= turbo_stream.prepend "tasks" do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %>
ゆっくり霊夢 「なるほど。 パーシャル名を気にするより、“この表示単位はこのComponent”って考えられるのね。」
ゆっくり魔理沙 「そうだぜ。 Hotwireでは“どのHTML断片を返すか”が大事だから、その断片が整理されているのはかなり効く。」
9.1.5 FrameやModalもComponent化できる
たとえば、モーダルの枠を共通化したいこともあります。
# app/components/modal_component.rb class ModalComponent < ViewComponent::Base def initialize(title:) @title = title end private attr_reader :title end
<!-- app/components/modal_component.html.erb -->
<div class="modal-backdrop">
<div class="modal-window">
<h2><%= title %></h2>
<%= content %>
</div>
</div>
使う側です。
<!-- app/views/tasks/new.html.erb -->
<%= turbo_frame_tag "modal" do %>
<%= render(ModalComponent.new(title: "New task")) do %>
<%= render "form", task: @task %>
<% end %>
<% end %>
こうすると、モーダルの見た目や構造を一箇所に寄せられます。
9.1.6 ViewComponentを入れすぎない判断も大事
とはいえ、全部をComponentにすればよいわけではありません。
パーシャルで十分な例:
- 単純なフォーム部品 - ロジックがほぼない表示片 - 一度しか使わない小さな断片
Component向きの例:
- 条件分岐が多い表示 - 表示ロジックをメソッドにしたい - 再利用したい見た目の単位 - Turbo Streamsで何度も返す断片
ゆっくり霊夢 「“見た目の部品”というより、“意味のある表示単位”を切り出す感じね。」
ゆっくり魔理沙 「その感覚がかなり大事だぜ。」
9.2 Form Object / Service Object
9.2.1 コントローラに全部書くとすぐ苦しい
ゆっくり霊夢 「CRUDだけならコントローラでもそんなに大変じゃなかったけど、実務だともっと複雑になるわよね。」
ゆっくり魔理沙 「そうだぜ。 たとえば“Taskを作ると同時にコメントも作る”“通知も飛ばす”“監査ログも残す”みたいなのが入ると、一気に重くなる。」
ありがちな膨らみ方はこうです。
def create @task = Task.new(task_params) if @task.save @task.comments.create!(body: params[:initial_comment]) if params[:initial_comment].present? Notification.create!(user: current_user, message: "Task created") AuditLog.create!(action: "task_created", record: @task) redirect_to @task, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end
これだと、コントローラがどんどん業務処理の置き場になってしまいます。
9.2.2 Form Objectで入力をまとめる
たとえば、「タスク作成フォームで initial_comment も受け取りたい」ケースを考えます。
Form Objectを作ります。
# app/forms/task_form.rb class TaskForm include ActiveModel::Model include ActiveModel::Attributes attribute :title, :string attribute :description, :string attribute :status, :string attribute :due_on, :date attribute :initial_comment, :string validates :title, presence: true validates :status, presence: true def save return false unless valid? ActiveRecord::Base.transaction do task.save! task.comments.create!(body: initial_comment) if initial_comment.present? end true rescue ActiveRecord::RecordInvalid false end def task @task ||= Task.new( title: title, description: description, status: status, due_on: due_on ) end end
フォーム側は Task ではなく TaskForm を使えます。
<!-- app/views/tasks/new.html.erb -->
<%= form_with model: @task_form, url: tasks_path do |form| %>
<div>
<%= form.label :title %><br>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :description %><br>
<%= form.text_area :description %>
</div>
<div>
<%= form.label :status %><br>
<%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
</div>
<div>
<%= form.label :due_on %><br>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.label :initial_comment %><br>
<%= form.text_area :initial_comment %>
</div>
<%= form.submit "Create task" %>
<% end %>
コントローラはこうなります。
# app/controllers/tasks_controller.rb def new @task_form = TaskForm.new(status: "todo") end def create @task_form = TaskForm.new(task_form_params) if @task_form.save redirect_to tasks_path, notice: "Task was successfully created." else render :new, status: :unprocessable_entity end end private def task_form_params params.require(:task_form).permit(:title, :description, :status, :due_on, :initial_comment) end
9.2.3 Form Objectの利点
- フォーム入力の責務をまとめられる - 複数モデルにまたがる入力を扱いやすい - コントローラが薄くなる - バリデーションをフォーム単位で定義できる
ゆっくり霊夢 「“保存対象のモデル”と“画面で受ける入力”は、必ずしも一致しないものね。」
ゆっくり魔理沙 「そこに気づくとForm Objectがかなりしっくり来るぜ。」
9.2.4 Service Objectで業務処理を切り出す
今度は「Task作成時に通知も監査ログも飛ばしたい」みたいな処理を Service に寄せます。
# app/services/tasks/create_service.rb module Tasks class CreateService def initialize(attributes:, actor:) @attributes = attributes @actor = actor end attr_reader :attributes, :actor, :task def call ActiveRecord::Base.transaction do @task = Task.create!(attributes) Notification.create!(user: actor, message: "Created task ##{task.id}") AuditLog.create!(action: "task_created", actor: actor, auditable: task) end true rescue ActiveRecord::RecordInvalid false end end end
コントローラです。
# app/controllers/tasks_controller.rb def create service = Tasks::CreateService.new(attributes: task_params, actor: current_user) if service.call @task = service.task redirect_to @task, notice: "Task was successfully created." else @task = service.task || Task.new(task_params) render :new, status: :unprocessable_entity end end
9.2.5 Form Object と Service Object をどう使い分けるか
ざっくりした目安はこうです。
Form Object向き
- 入力項目が複数モデルにまたがる - フォーム特有のバリデーションがある - モデルそのものではない入力概念がある
Service Object向き
- 作成/更新時の業務処理が重い - 通知、監査ログ、連携など副作用が多い - トランザクション境界を明示したい
組み合わせることもあります。
Form Object が入力を受ける ↓ Service Object が保存処理を実行する
9.2.6 Hotwireと組み合わせるときの考え方
Hotwireで大事なのは、ビューの更新と業務処理を混ぜすぎないことです。
悪い例:
def create @task = Task.new(task_params) if @task.save Notification.create!(...) AuditLog.create!(...) respond_to do |format| format.turbo_stream format.html { redirect_to tasks_path } end else ... end end
少し整理した例:
def create service = Tasks::CreateService.new(attributes: task_params, actor: current_user) if service.call @task = service.task respond_to do |format| format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully created." } end else @task = service.task || Task.new(task_params) render :new, status: :unprocessable_entity end end
この形のほうが、何を保存しているか と 何を返しているか が分かれます。
9.3 Turbo Streamsの設計指針
9.3.1 Streamが増えると“どこを更新しているのか”が見えにくい
ゆっくり霊夢 「Turbo Streamsって便利だけど、増えると散らかりそうな気もするわ。」
ゆっくり魔理沙 「そこが実務で大事なポイントだぜ。 Streamsは便利すぎるから、何も考えずに増やすと“どの更新がどこへ飛ぶか”が見えなくなる。」
たとえばこんな create.turbo_stream.erb は、規模が大きくなると読みづらいです。
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace "task_form", partial: "tasks/form", locals: { task: Task.new } %>
<%= turbo_stream.update "flash", partial: "shared/flash", locals: { notice: "Created!" } %>
<%= turbo_stream.replace "sidebar_counts", partial: "tasks/sidebar_counts", locals: { counts: @counts } %>
<%= turbo_stream.update "page_title", "Tasks (#{@counts[:all]})" %>
できるけど、責務が広すぎます。
9.3.2 “意味のある更新単位”を決める
まず大事なのは、DOMの更新単位を意味で区切ることです。
たとえばタスク一覧画面ならこうです。
- task_form : フォーム領域 - tasks : 一覧領域 - flash : 通知領域 - sidebar_counts : 件数表示
content や main みたいな曖昧なidではなく、何の責務の領域か がわかる名前にします。
<div id="task_form">
<%= render "form", task: Task.new %>
</div>
<div id="tasks">
<% @tasks.each do |task| %>
<%= render(TaskCardComponent.new(task: task)) %>
<% end %>
</div>
<div id="flash">
<%= render "shared/flash" %>
</div>
9.3.3 Streamテンプレートにロジックを書きすぎない
悪い例です。
<% if @task.priority == "high" %>
<%= turbo_stream.prepend "tasks", partial: "tasks/high_priority_task_card", locals: { task: @task } %>
<% else %>
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<% end %>
<% if @task.assignee.present? %>
<%= turbo_stream.update "assignee_badge", @task.assignee.name %>
<% end %>
Streamテンプレートに分岐が増えると、更新仕様が読みにくくなります。
改善例としては、
- 表示の違いは Component / partial 側へ寄せる
- Streamテンプレートは「どこをどう更新するか」だけ書く
です。
<%= turbo_stream.prepend "tasks" do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %> <%= turbo_stream.update "flash" do %> <%= render "shared/flash", notice: "Task created." %> <% end %>
9.3.4 controller主導とbroadcast主導を混ぜすぎない
これもありがちな混乱ポイントです。
- create.turbo_stream.erb で prepend - after_create_commit でも broadcast_prepend_to
この2つを何も考えず併用すると、二重反映の原因になります。
方針は先に決めたほうがいいです。
パターンA: リクエスト応答はcontroller、他ユーザー同期はbroadcast
パターンB: できるだけbroadcastへ寄せる
パターンC: 単純画面はcontroller主導だけで済ませる
学習本としては、まずこう整理するとわかりやすいです。
単一ユーザー向けの画面更新: - controller + *.turbo_stream.erb 複数ユーザー同期: - model callback + broadcast_*
9.3.5 Streamを“操作名”ではなく“UI目的”で考える
たとえば create 後にやりたいことは、技術的には複数あります。
- 一覧へ追加する - フォームを空に戻す - フラッシュを出す
これを単に「append」「replace」「update」と考えるより、UI目的 で整理すると保守しやすいです。
目的: - 新しいタスクを見せる - 次の入力をしやすくする - 保存成功を伝える
その結果として、操作がこうなります。
<%= turbo_stream.prepend "tasks" do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %> <%= turbo_stream.replace "task_form" do %> <%= render "tasks/form", task: Task.new %> <% end %> <%= turbo_stream.update "flash" do %> <%= render "shared/flash", notice: "Task created." %> <% end %>
9.3.6 Streamの責務を寄せる小さなヘルパー
たとえば更新パターンが何度も出るなら、ヘルパーや小さなオブジェクトに寄せるのもありです。
# app/helpers/tasks_helper.rb module TasksHelper def render_task_card(task) render(TaskCardComponent.new(task: task)) end end
<%= turbo_stream.replace @task do %> <%= render_task_card(@task) %> <% end %>
これは小さい工夫ですが、Streamテンプレートがかなり読みやすくなります。
9.3.7 “局所更新”と“全体整合性”のバランス
ゆっくり霊夢 「局所更新ばかりしていると、逆にどこかの数字とか一覧件数がズレたりしない?」
ゆっくり魔理沙 「その通りだぜ。 Hotwire実務でかなり大事なのは、局所更新の快適さ と 画面全体の整合性 のバランスなんだ。」
たとえばタスクを1件追加したら、
- 一覧は増える
- 件数表示も増える
- サイドバーの未完了数も変わる
ということがあります。
このとき、どこまでを同時更新するかは設計判断です。
更新の影響が大きいなら: - 必要な関連領域もまとめて更新する 影響が限定的なら: - まず主領域だけ更新する
なんでも全部同期しようとすると、Streamが巨大化します。
9.4 N+1とパフォーマンス
9.4.1 HotwireはHTMLを返すぶん、クエリ効率が効きやすい
ゆっくり霊夢 「パフォーマンスっていうと、JSアプリの話ばかり注目されがちだけど、Hotwireでも大事?」
ゆっくり魔理沙 「むしろかなり大事だぜ。 HotwireはサーバーでHTMLを組み立てるから、ビュー描画時のクエリ数やレンダリング回数 がそのまま効いてくる。」
典型的なN+1例を見てみましょう。
<!-- app/views/tasks/index.html.erb -->
<div id="tasks">
<% @tasks.each do |task| %>
<section>
<h2><%= task.title %></h2>
<p>Assignee: <%= task.assignee.name %></p>
<p>Comments: <%= task.comments.count %></p>
</section>
<% end %>
</div>
これで @tasks を普通に Task.all で取ると、
- taskごとに assignee を取得
- taskごとに comments を数える
となって、N+1の原因になります。
9.4.2 includes で関連を先読みする
まず基本はこれです。
# app/controllers/tasks_controller.rb def index @tasks = Task.includes(:assignee, :comments).order(created_at: :desc) end
これで assignee へのアクセスはかなり改善されます。
ただし comments.count は場合によっては別クエリになるので、使い方に注意が必要です。
9.4.3 counter_cache を検討する
コメント件数を毎回表示したいなら、counter cache が向いていることがあります。
マイグレーション例です。
bin/rails generate migration AddCommentsCountToTasks comments_count:integer
# db/migrate/xxxxxxxxxxxxxx_add_comments_count_to_tasks.rb class AddCommentsCountToTasks < ActiveRecord::Migration[7.0] def change add_column :tasks, :comments_count, :integer, default: 0, null: false end end
コメントモデルです。
# app/models/comment.rb class Comment < ApplicationRecord belongs_to :task, counter_cache: true end
ビューではこうできます。
<p>Comments: <%= task.comments_count %></p>
これなら件数表示のたびに count クエリを打たなくて済みます。
9.4.4 Stream更新でもクエリは発生する
Turbo Streamsで1行だけ更新する場合でも、その描画に必要なデータが不足していれば追加クエリが発生します。
たとえば update 後にこう返しているとします。
<%= turbo_stream.replace @task do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %>
TaskCardComponent の中で task.assignee.name を読んでいたら、@task に assignee が読み込まれていない場合にクエリが飛びます。
コントローラで明示的に読み直すこともあります。
def update if @task.update(task_params) @task = Task.includes(:assignee).find(@task.id) respond_to do |format| format.turbo_stream format.html { redirect_to tasks_path, notice: "Task updated." } end else ... end end
ゆっくり霊夢 「一覧画面だけ気をつければ終わりじゃないのね。」
ゆっくり魔理沙 「そうだぜ。 Streamsで返す断片も立派なビューだから、そこでもクエリ意識は必要なんだ。」
9.4.5 partial / component の描画回数も意識する
一覧が大量件数になると、クエリだけでなく描画回数も効いてきます。
<% @tasks.each do |task| %> <%= render(TaskCardComponent.new(task: task)) %> <% end %>
これはわかりやすいですが、件数が多いとテンプレート描画コストも増えます。
対策の方向性はこうです。
- 一覧の件数を絞る(ページネーション) - 必要以上に重い表示をしない - 関連データを全部見せすぎない - Streamで頻繁に全件再描画しない
9.4.6 “全部replace” を乱用しない
悪い例です。
<%= turbo_stream.replace "tasks" do %>
<%= render partial: "tasks/list", locals: { tasks: @tasks } %>
<% end %>
毎回一覧全体をreplaceすると、
- クエリも増えやすい
- 描画コストも大きい
- スクロール位置やフォーカスも崩れやすい
という問題が出ます。
可能なら、局所更新を優先します。
<%= turbo_stream.prepend "tasks" do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %> <%= turbo_stream.replace @task do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %> <%= turbo_stream.remove @task %>
9.4.7 パフォーマンスを測る視点
最後に、実務で見るべき観点を整理します。
- 1リクエストあたりのSQL件数 - includesで解決できるN+1がないか - Streamレスポンスで重い関連を読んでいないか - 一覧全体再描画を乱用していないか - 件数が増えたときの描画時間
ゆっくり霊夢 「Hotwireって“軽そう”な印象があるけど、ちゃんと設計しないと普通に重くなるのね。」
ゆっくり魔理沙 「そうだぜ。 でも逆に言えば、Railsの基本をちゃんと守ればかなり素直に速くできる。」
この章のまとめ
ゆっくり霊夢 「実務っぽさがかなり増してきたわね。 “Hotwireでどう作るか” だけじゃなくて、“どう整理するか” が見えてきた感じ。」
ゆっくり魔理沙 「それがこの章の狙いだぜ。 この章のポイントをまとめるとこうなる。」
- HotwireではHTML断片の設計が重要なので、ViewComponentと相性が良い - 表示ロジックが増えるなら partial だけでなく Component を検討する - Form Object はフォーム入力の責務整理に向く - Service Object は作成・更新時の業務処理の切り出しに向く - Turbo Streamsは更新対象の責務を明確にし、局所更新を意識すると保守しやすい - controller主導の更新と broadcast主導の更新は、方針を混ぜすぎないほうがよい - HotwireでもN+1や描画コストは重要で、includesやcounter_cacheが効く - 一覧全体replaceの乱用より、prepend / replace / remove の局所更新が基本
練習問題
問1
partial より ViewComponent が向いているケースを2つ以上挙げてください。
問2
Form Object と Service Object は、それぞれどのような責務に向いていますか。
問3
Turbo Streamsの更新対象idに content や main のような曖昧な名前を避けたほうがよいのはなぜですか。
問4
次のコードにはどんなパフォーマンス上の注意点がありますか。
<% @tasks.each do |task| %> <p>Assignee: <%= task.assignee.name %></p> <p>Comments: <%= task.comments.count %></p> <% end %>
問5
なぜ turbo_stream.replace "tasks" で一覧全体を毎回更新するより、prepend や対象行の replace を優先したほうがよいのでしょうか。
章末ミニコラム: Hotwireは“簡単に作れる”からこそ設計差が出る
ゆっくり霊夢 「Hotwireって、少ないコードでけっこう動いちゃうのが魅力よね。」
ゆっくり魔理沙 「それは本当に強みだぜ。 でも同時に、“動くからそのまま進める” をやると、後から読みづらさが一気に来る。」
たとえば次のような差は、最初は小さく見えても後で大きくなります。
- パーシャルを整理しているか - Streamの責務が明確か - 入力処理と業務処理が分かれているか - 表示のためのクエリを意識しているか
Hotwireは、Reactのような重い構成管理がないぶん、Rails流の設計力がそのまま品質に出やすい技術です。
ゆっくり魔理沙 「つまりHotwireは“雑に作っても動く”けど、“丁寧に作るとかなり強い”んだぜ。」
Chapter 10: テスト戦略
はじめに
ゆっくり霊夢 「ここまででHotwireアプリ、かなり実用的になってきたわね。 でもこうなると、逆に“ちゃんと壊れないの?”が気になってくるわ。」
ゆっくり魔理沙 「そこで第10章だぜ。 HotwireはHTML中心だから一見テストしやすそうなんだが、TurboやStimulusが入ると“どこまでを何でテストするか” を整理しないと混乱しやすい。」
ゆっくり霊夢 「全部システムテストで見ればいい、ってわけでもないのね。」
ゆっくり魔理沙 「そうだぜ。 この章では、Capybaraでユーザー操作を検証するところ、Turbo特有の挙動を見るところ、Stimulusの小さい振る舞いをどう見るか、そしてCIで安定して回す考え方までまとめる。」
この章では、次の4つを扱います。
- 10.1 システムテスト(Capybara)
- 10.2 Turbo対応テスト
- 10.3 Stimulusのテスト
- 10.4 CIでの運用
10.1 システムテスト(Capybara)
10.1.1 Hotwireアプリではシステムテストが特に大事
ゆっくり霊夢 「モデルテストとかリクエストテストもあるけど、Hotwireだと何が一番大事なの?」
ゆっくり魔理沙 「まず一番わかりやすく効くのは、システムテスト だぜ。 理由は単純で、Hotwireは“ユーザーから見た画面の変化”が価値だからな。」
たとえば次のようなことは、システムテストで見る価値が高いです。
- タスク作成後に一覧へ反映される - インライン編集でフォームに切り替わる - 削除後に行が消える - モーダルが開く - フラッシュが表示される
つまり、“最終的にブラウザでどう見えるか” を検証するのが大事です。
10.1.2 まずは最小のシステムテストを書く
Rails の system test では、Capybara を使ってブラウザ操作を記述できます。 まずはタスク作成の基本からです。
# test/system/tasks_test.rb require "application_system_test_case" class TasksTest < ApplicationSystemTestCase test "creating a task" do visit tasks_path fill_in "Title", with: "Learn Hotwire testing" select "To Do", from: "Status" fill_in "Description", with: "Write system tests" fill_in "Due on", with: Date.current + 3 click_on "Create task" assert_text "Learn Hotwire testing" end end
ゆっくり霊夢 「これ、ユーザーがやることそのまま書けるのね。」
ゆっくり魔理沙 「そうだぜ。 Hotwireでは、むしろこれくらい“操作ベース”で読むほうがわかりやすい。」
10.1.3 フィクスチャを使う
更新や削除をテストするなら、既存データがあると便利です。
# test/fixtures/tasks.yml one: title: Learn Turbo description: Basic CRUD with Hotwire status: todo due_on: 2026-04-10 position: 1 two: title: Learn Stimulus description: Add small UI interactions status: doing due_on: 2026-04-12 position: 2
これを使うと、テスト内でこう書けます。
# test/system/tasks_test.rb require "application_system_test_case" class TasksTest < ApplicationSystemTestCase setup do @task = tasks(:one) end test "updating a task" do visit tasks_path click_on "Edit", match: :first fill_in "Title", with: "Learn Turbo deeply" click_on "Save" assert_text "Learn Turbo deeply" end end
10.1.4 Capybaraで要素の存在を丁寧に見る
Hotwireアプリでは、ただ assert_text するだけでなく、どの要素がどう変化したか を見ると壊れにくくなります。
たとえばタスク一覧が id="tasks" なら、範囲を絞れます。
test "creating a task adds it to the tasks list" do visit tasks_path fill_in "Title", with: "Task from system test" select "To Do", from: "Status" click_on "Create task" within "#tasks" do assert_text "Task from system test" end end
こうすると、別の場所にたまたま同じ文言があっても誤判定しにくいです。
10.1.5 見つかりにくい要素には明示的な属性を付ける
ゆっくり霊夢 「でもHotwireって、部分更新された要素とかモーダルとか、テストで取りにくいことない?」
ゆっくり魔理沙 「あるぜ。だから実務では、テストしやすいHTML を少し意識するとかなり楽だ。」
たとえば、編集リンクに data-testid 的な属性を入れることがあります。
<%= link_to "Edit",
edit_task_path(task),
data: { turbo_stream: true, testid: "edit-task-#{task.id}" } %>
CapybaraではCSSセレクタで取れます。
find('[data-testid="edit-task-1"]').click
あるいは、わかりやすいidを付けるだけでも十分です。
<section id="<%= dom_id(task) %>"> ... </section>
within "#task_#{@task.id}" do assert_text @task.title end
10.1.6 JavaScriptありのシステムテストを使う
Turbo や Stimulus をちゃんと動かすには、JS対応ドライバでテストする必要があります。
Rails の system test では driven_by を設定します。
# test/application_system_test_case.rb require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] end
これで、Turbo の部分更新や Stimulus の動きもブラウザ上で確認できます。
ゆっくり霊夢 「ここでheadless browserが効いてくるのね。」
ゆっくり魔理沙 「そうだぜ。 Hotwireを“本当にユーザー視点で”試すなら、ここはかなり重要だ。」
10.1.7 システムテストの粒度を欲張りすぎない
システムテストは強いですが、なんでも全部入れると重くなります。
おすすめは、ユーザー価値のある主要フロー に絞ることです。
- タスクを作成できる - タスクをその場編集できる - タスクを削除できる - モーダルから作成できる - 並び替え結果が保存される
逆に、細かい内部条件分岐まで全部システムテストへ押し込むのは重くなりやすいです。
10.2 Turbo対応テスト
10.2.1 Turboが入ると“画面遷移しない成功”が増える
ゆっくり霊夢 「Turboがあると、見た目は変わるけどフルリロードしてない、みたいなことが多いわよね。」
ゆっくり魔理沙 「そうだぜ。だからTurbo対応テストでは、“最終的にDOMがどうなったか” を見る意識が大事なんだ。」
たとえば Turbo Stream で一覧に追加されるなら、次を見ます。
test "creating a task updates the list with turbo stream" do visit tasks_path fill_in "Title", with: "Turbo Stream task" select "To Do", from: "Status" click_on "Create task" within "#tasks" do assert_text "Turbo Stream task" end end
ここでは「リダイレクト先URL」より、「一覧が更新されたこと」の方が本質です。
10.2.2 インライン編集のテスト
インライン編集はTurbo Streamの代表例です。 表示からフォームへ切り替わり、保存後に再び表示へ戻る流れを見ます。
test "editing a task in place" do task = tasks(:one) visit tasks_path within "#task_#{task.id}" do click_on "Edit" end within "#task_#{task.id}" do assert_selector "form" fill_in "Title", with: "Edited in place" click_on "Save" end within "#task_#{task.id}" do assert_text "Edited in place" assert_no_selector "form" end end
ゆっくり霊夢
「assert_no_selector "form" がいいわね。
ちゃんと“表示へ戻った”のがわかる。」
ゆっくり魔理沙 「そうだぜ。 Hotwireでは“切り替わった先の状態”を明示するのが大事なんだ。」
10.2.3 削除テストは“見えなくなる”を確認する
削除はシンプルですが、Turbo Streamの remove をちゃんと見られます。
test "destroying a task removes it from the list" do task = tasks(:one) visit tasks_path within "#task_#{task.id}" do click_on "Delete" end assert_no_selector "#task_#{task.id}" end
必要なら件数変化も確認できます。
test "destroying a task reduces visible task count" do visit tasks_path initial_count = all("#tasks .task-card").count click_on "Delete", match: :first assert_equal initial_count - 1, all("#tasks .task-card").count end
10.2.4 Turbo Framesのテスト
Frames の場合は、フレーム内だけ更新されることを見ます。
たとえば詳細フレームがあるならこうです。
test "showing a task updates the task details frame" do task = tasks(:one) visit tasks_path within "#task_#{task.id}" do click_on task.title end within "turbo-frame#task_details" do assert_text task.title assert_text task.status end end
ここで重要なのは、ページ全体ではなくフレーム領域を見ていることです。
10.2.5 Turbo Streamレスポンス自体をリクエストテストで見る
システムテストだけでなく、レスポンス形式 を確認したいならリクエストテストも使えます。
# test/controllers/tasks_controller_test.rb require "test_helper" class TasksControllerTest < ActionDispatch::IntegrationTest test "create responds with turbo stream" do assert_difference("Task.count", 1) do post tasks_path, params: { task: { title: "Turbo response test", status: "todo" } }, headers: { "Accept" => "text/vnd.turbo-stream.html" } end assert_response :success assert_equal "text/vnd.turbo-stream.html; charset=utf-8", response.media_type + "; charset=utf-8" assert_includes response.body, %(<turbo-stream) end end
これはブラウザ動作の代わりにはなりませんが、 「このアクションは turbo-stream を返すはず」 を保証するのに便利です。
10.2.6 非同期っぽく見えても“待つ”を意識する
Turbo や Stimulus を含むシステムテストでは、DOM反映が少し遅れることがあります。 Capybara は待機してくれますが、無理に即時判定しない のが大事です。
悪い例:
click_on "Create task" assert page.html.include?("New task")
おすすめ:
click_on "Create task" assert_text "New task"
あるいは:
assert_selector "#tasks", text: "New task"
Capybaraの待機機構を使うほうが安定します。
10.2.7 複数箇所更新は“全部見る”より“重要な結果を見る”
Turbo Streamでは1回の操作で複数箇所更新できます。
- 一覧へ追加 - フォームを空に戻す - フラッシュ表示
これを全部1テストで見てもいいですが、壊れやすくなりがちです。 実務では 主要な結果を中心に確認 するのがおすすめです。
test "creating a task shows it in the list" do ... end test "creating a task resets the form" do ... end
1つのテストに責務を詰め込みすぎないほうが読みやすいです。
10.3 Stimulusのテスト
10.3.1 Stimulusは“全部ブラウザE2E”にしなくていい
ゆっくり霊夢 「Stimulusのテストって難しそう。 小さいJSのために毎回ブラウザテストするの、ちょっと重そうだわ。」
ゆっくり魔理沙 「そこは考え方が大事だぜ。 Stimulusは小さい責務に分割するから、全部を重いE2Eで見る必要はない。」
たとえば次のように分けて考えられます。
- 重要なユーザー操作 → system test - 小さいDOM操作 → JSテストまたは限定的なsystem test
10.3.2 まずは system test で十分なケース
たとえば文字数カウンタなら、システムテストでも十分です。
test "title length counter updates while typing" do visit new_task_path fill_in "Title", with: "Hotwire" assert_text "7 / 100" end
あるいはトグルUIなら:
test "details can be toggled" do visit tasks_path click_on "Toggle details" assert_text "Hidden content" end
このように、ユーザー視点で意味のあるStimulus挙動 は system test でまず十分です。
10.3.3 JS単体寄りに考える対象
一方で、Stimulus controller が少し複雑になると、 DOMを直接立てて controller を動かす形のJSテストも考えたくなります。
たとえば次の controller です。
// app/javascript/controllers/length_counter_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["input", "output"] static values = { max: Number } update() { const length = this.inputTarget.value.length this.outputTarget.textContent = `${length} / ${this.maxValue}` this.outputTarget.classList.toggle("over-limit", length > this.maxValue) } }
これをテストするとしたら、考え方はこうです。
1. DOM断片を作る 2. controllerを接続する 3. input値を変える 4. update() を呼ぶ 5. outputのtextContent / classを確認する
擬似コードイメージです。
import { Application } from "@hotwired/stimulus" import LengthCounterController from "controllers/length_counter_controller" describe("LengthCounterController", () => { let application let element beforeEach(() => { document.body.innerHTML = ` <div data-controller="length-counter" data-length-counter-max-value="5"> <input data-length-counter-target="input"> <div data-length-counter-target="output"></div> </div> ` application = Application.start() application.register("length-counter", LengthCounterController) element = document.querySelector('[data-controller="length-counter"]') }) afterEach(() => { application.stop() document.body.innerHTML = "" }) it("updates the output text", () => { const input = element.querySelector('[data-length-counter-target="input"]') const output = element.querySelector('[data-length-counter-target="output"]') input.value = "hello" input.dispatchEvent(new Event("input")) expect(output.textContent).toBe("5 / 5") }) })
ゆっくり霊夢 「なるほど。 StimulusってDOMに密着してるから、テストもDOMベースで考えるのね。」
ゆっくり魔理沙 「そうだぜ。 Reactみたいなコンポーネントテストとは少し感覚が違う。」
10.3.4 connect / disconnect を意識する
Stimulus では connect() と disconnect() が大事です。
Turbo で画面断片が入れ替わると、controller は付いたり外れたりします。
たとえば:
// app/javascript/controllers/highlight_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.classList.add("highlight") } disconnect() { this.element.classList.remove("highlight") } }
このタイプの controller は、接続時に正しく初期化し、外れたときに掃除するか を意識しておくとよいです。
10.3.5 addEventListener を使うcontrollerは特に注意
Stimulusで生DOMイベントを手動登録している場合、disconnect() で外さないとテストでも本番でも不安定になります。
悪い例:
connect() { window.addEventListener("resize", this.handleResize) }
改善例:
connect() { window.addEventListener("resize", this.handleResize) } disconnect() { window.removeEventListener("resize", this.handleResize) }
この手のcontrollerは、システムテストより単体寄りのJSテストの方が安心 になることがあります。
10.3.6 Stimulusのテスト方針まとめ
実務での考え方をまとめるとこうです。
シンプルなUI補助: - system test で十分なことが多い やや複雑なcontroller: - DOMベースのJSテストも検討 副作用が大きいcontroller: - connect / disconnect / イベント解除まで意識する
つまり、Stimulusは「全部重くテストする」より、 責務の大きさに応じてテスト方法を選ぶ のが大事です。
10.4 CIでの運用
10.4.1 ローカルで通るだけでは足りない
ゆっくり霊夢 「テストを書いても、手元でしか回してないと結局不安よね。」
ゆっくり魔理沙 「その通りだぜ。 Hotwire系は特に、JSつきブラウザテストがCIで安定して回るか がかなり大事なんだ。」
CIで見たいものは大きくこうです。
- モデルテスト - リクエスト/コントローラテスト - システムテスト - 必要ならJSテスト
10.4.2 最低限のCI方針
本の読者向けには、まず次の方針が現実的です。
1. Ruby環境を入れる 2. DBを用意する 3. assets / JS環境を整える 4. test を実行する 5. headless browser で system test を回す
たとえば GitHub Actions なら、イメージはこうです。
# .github/workflows/ci.yml name: CI on: push: pull_request: jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: password ports: - 5432:5432 env: RAILS_ENV: test DATABASE_URL: postgres://postgres:password@localhost:5432/app_test steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true - name: Set up Chrome uses: browser-actions/setup-chrome@v1 - name: Install dependencies run: | bundle install bin/rails db:prepare - name: Run tests run: | bin/rails test bin/rails test:system
これはあくまで最小イメージですが、 “system testをCIでも回す” ことが重要です。
10.4.3 システムテストがCIで不安定なときの見直しポイント
Hotwire系のCIでありがちな問題は、いわゆる flaky test です。
よくある原因は次のあたりです。
- 即時にassertしてしまっている - セレクタが曖昧 - Turbo反映待ちを考えていない - モーダルやフレーム内の対象を絞れていない - テストデータが他ケースと干渉している
改善の方向性はこうです。
- assert_text / assert_selector を使って待機を活かす - within で範囲を絞る - id や data-testid を明確にする - 1テスト1責務にする
10.4.4 スクリーンショットを活用する
CIで system test が失敗したとき、HTMLログだけでは見えにくいことがあります。 スクリーンショット保存を有効にしておくと助かります。
Rails の system test では、失敗時スクリーンショットを扱いやすいです。 基本の設定を活かしつつ、必要なら保存先を意識します。
# test/application_system_test_case.rb class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] # Rails標準の失敗時スクリーンショット機能を利用 end
CIで artifacts として回収できるようにすると便利です。
- name: Upload screenshots if: failure() uses: actions/upload-artifact@v4 with: name: system-test-screenshots path: tmp/screenshots
ゆっくり霊夢 「画面系テストは、失敗時の見た目が残るとかなり助かるわね。」
ゆっくり魔理沙 「そうだぜ。 Turboの問題って“何が表示されたか”が本質だからな。」
10.4.5 テストを段階分けする
CIを速く保つには、テストを段階分けするのも有効です。
pushごと: - モデル - リクエスト - 軽めのsystem test 夜間 or mainブランチ: - system test全部 - JSテスト全部
もちろんプロジェクト規模次第ですが、 全部を毎回フルで回すか、用途で分けるか は運用設計のポイントです。
10.4.6 Hotwire向けCI運用の実務感
HotwireアプリのCIで大事なのは、次の感覚です。
- サーバー側ロジックだけでなく画面動作も壊れる - Turbo/Stimulusは見た目が変わるので、system test価値が高い - でもsystem testを雑に書くとCIが不安定になる
だからこそ、
- 主要フローを厳選する - セレクタを安定させる - 待機込みのassertを使う - 失敗時の調査手段を残す
が重要です。
この章のまとめ
ゆっくり霊夢 「テスト戦略って、単に“いっぱい書く”じゃなくて、“何をどこで保証するか”を分けるのが大事なのね。」
ゆっくり魔理沙 「その通りだぜ。 Hotwireアプリでは特に、その切り分けが効く。」
この章のポイントをまとめるとこうです。
- Hotwireアプリでは、ユーザー視点の挙動を確認する system test の価値が高い - Capybaraでは、範囲を絞った assert でDOM変化を丁寧に確認すると安定しやすい - Turbo対応テストでは、URL遷移より最終DOM状態を見る意識が大事 - Turbo Frames はフレーム内、Turbo Streams は更新対象要素の変化を確認する - Stimulusは、重要な挙動は system test、複雑なものはDOMベースのJSテストも検討する - connect / disconnect やイベント解除は Stimulus特有の注意点 - CIでは headless browser を使って system test を安定実行する - flaky test対策として、待機込みassert、明確なセレクタ、スクリーンショット回収が有効
練習問題
問1
Hotwireアプリで system test の価値が高いのはなぜですか。
問2
Turbo Streamで一覧更新をテストするとき、assert_text だけでなく within "#tasks" のように範囲を絞る利点は何ですか。
問3
インライン編集のテストで、保存後に assert_no_selector "form" を入れる意味は何ですか。
問4
Stimulus controller の connect() と disconnect() を意識したほうがよいのはなぜですか。
問5
CIで system test が不安定になるとき、見直すべきポイントを2つ以上挙げてください。
章末ミニコラム: Hotwireのテストは“画面が正しく動くか”を恐れず見る
ゆっくり霊夢 「テストって、ついモデルとかサービスとか“内側”ばかり見たくなるのよね。」
ゆっくり魔理沙 「それも大事なんだが、Hotwireでは“外側”を見る勇気もかなり大事だぜ。」
Hotwireの価値は、最終的にはこういうところに出ます。
- その場で編集できる - すぐ反映される - モーダルが自然に開く - エラーが気持ちよく見える
つまり、画面がどう動くか が本質です。 だから system test は“重いから避けるもの”ではなく、価値のある挙動を守るテストとして使うのが向いています。
ゆっくり魔理沙 「Hotwireは“見た目の気持ちよさ”を作る技術だから、その気持ちよさをテストで守るんだぜ。」
Chapter 11: デプロイと運用
はじめに
ゆっくり霊夢 「ここまででHotwireアプリはかなり形になったわね。 でも“ローカルで動く”と“本番で安定して動く”は別の話よね。」
ゆっくり魔理沙 「その通りだぜ。 特にHotwireアプリは、普通のRails運用に加えて Turbo Streams と Action Cable のリアルタイム部分 もあるから、構成・性能・監視までちゃんと考えないといけない。」 ([Ruby on Rails Guides][2])
ゆっくり霊夢 「つまりこの章は、“Hotwireを本番でちゃんと生かすにはどうするか”って話なのね。」
ゆっくり魔理沙 「そうだぜ。 派手なコードより、ここがちゃんとしてるかどうかで実務の安心感がかなり変わる。」
この章では、次の4つを扱います。
- 11.1 本番環境の構成
- 11.2 ActionCableのスケーリング
- 11.3 パフォーマンス最適化
- 11.4 ログと監視
11.1 本番環境の構成
11.1.1 まず全体像を整理する
ゆっくり霊夢 「本番環境って、Hotwireだから特別な何かが必要なの?」
ゆっくり魔理沙 「特別というより、Rails本体 + DB + アセット配信 + リアルタイム接続 をどう支えるか、って考えるのが大事だぜ。」
Hotwireアプリの最小本番構成イメージはこうです。
[ User Browser ]
|
v
[ Reverse Proxy / Load Balancer ]
|
+----------------------+
| |
v v
[ Rails App Server ] [ Action Cable ]
|
v
[ Database ]
ただし、最初から分離必須ではありません。 小規模なら RailsアプリとAction Cableを同居 させても十分です。Action Cable 公式ガイドでも、アプリサーバーとCableを同一プロセスまたは別プロセスで構成できる前提になっています。 ([Ruby on Rails Guides][2])
11.1.2 小規模構成は“まずシンプル”でよい
最初の本番構成としては、かなり素直に考えて大丈夫です。
- Rails app (Puma) - PostgreSQL - Redis または Cable用バックエンド - Nginx / LB / ingress など - object storage / CDN は必要に応じて
Rails の本番チューニングガイドでも、まずは Puma を中心にワーカー数・スレッド数・メモリ使用量を測りながら調整する 方向が基本です。 ([Ruby on Rails Guides][3])
たとえば config/puma.rb のイメージです。
# config/puma.rb threads_count = ENV.fetch("RAILS_MAX_THREADS", 5) threads threads_count, threads_count workers ENV.fetch("WEB_CONCURRENCY", 2) preload_app! port ENV.fetch("PORT", 3000) plugin :tmp_restart
ゆっくり霊夢 「いきなり難しくしないのが大事なのね。」
ゆっくり魔理沙 「そうだぜ。 Hotwireだからって最初から全部を分離構成にする必要はない。」
11.1.3 Rails 8系なら Kamal を視野に入れやすい
最近のRailsは、Rails 8 で Kamal 2 が標準寄りのデプロイ体験として前面に出ている のが特徴です。公式ブログでも Rails 8 は “No PaaS Required” を打ち出していて、Linuxサーバーへのデプロイをかなり素直にしています。 ([Rails][1])
本の文脈では、こんな書き方がしやすいです。
bin/kamal setup bin/kamal deploy
ただし本書では、Kamalそのものの詳細説明よりも、Hotwireを動かすための本番設計 に重心を置くのが自然です。
11.1.4 本番で意識する環境変数
最低限、次のような環境変数整理は必要です。
RAILS_ENV=production RAILS_LOG_TO_STDOUT=1 RAILS_SERVE_STATIC_FILES=1 DATABASE_URL=postgres://... REDIS_URL=redis://... RAILS_MASTER_KEY=... RAILS_MAX_THREADS=5 WEB_CONCURRENCY=2
とくにHotwireで Turbo Streams / Action Cable を使う場合は、WebSocket系バックエンドの接続先 を本番環境で明示することが大事です。 ([Ruby on Rails Guides][2])
11.1.5 Action Cableの接続先設定
たとえば config/cable.yml はこうなります。
# config/cable.yml production: adapter: redis url: <%= ENV.fetch("REDIS_URL") %> channel_prefix: myapp_production
Action Cable公式ガイドでも、本番ではRedisアダプタなどを使ってpub/subを支える構成 が基本です。 ([Ruby on Rails Guides][2])
また、ルーティングでは通常こうです。
# config/routes.rb Rails.application.routes.draw do mount ActionCable.server => "/cable" resources :tasks root "tasks#index" end
11.1.6 アセットとキャッシュも本番構成の一部
Hotwireアプリでも、最終的にはHTMLとCSSとJSを返します。 Rails 8 では Propshaft や Solid Cache など、周辺の標準も少しずつ進化しています。 ([Ruby on Rails Guides][4])
まずはこのくらいの認識で十分です。
- CSS/JS は fingerprint 付きで配信する - reverse proxy / CDN を必要に応じて使う - fragment caching を検討する - Cable の接続数と Web の応答性能を分けて考える
11.1.7 本番構成の第一原則
ゆっくり霊夢 「結局、どこから考えればいいの?」
ゆっくり魔理沙 「まずはこれだぜ。」
1. Webリクエストは安定して速いか 2. Cable接続は維持できるか 3. DBとキャッシュは無理してないか 4. 障害時に見えるか
つまり、機能のデプロイ ではなく 運用可能な構成 を作る意識が大事です。
11.2 ActionCableのスケーリング
11.2.1 Action Cableは“繋ぎっぱなし”が前提
ゆっくり霊夢 「Action Cableって普通のHTTPと何がそんなに違うの?」
ゆっくり魔理沙 「一番大きいのは、接続を開きっぱなしにする ことだぜ。 HTTPリクエストは来て返して終わりだが、WebSocketは接続維持が前提なんだ。」 ([Ruby on Rails Guides][2])
だから本番では、単なるリクエスト数だけでなく、
- 同時接続数 - 接続維持時間 - ブロードキャスト頻度 - サーバー1台あたりのメモリ使用量
を意識する必要があります。
11.2.2 最初は同居でいいが、増えたら分離を考える
小規模ならこうでも動きます。
Puma ├─ Web request └─ Action Cable
でも接続数が増えてくると、WebとCableで性質が違うので分けたくなります。
[ LB ] ├─ Web app server群 └─ Cable server群
Action Cableガイドにも、スタンドアロン構成 と 同居構成 の話があります。規模が大きくなるなら分離が自然です。 ([Ruby on Rails Guides][2])
11.2.3 Redisやバックエンドは“中継役”として重要
複数プロセス・複数サーバーで Action Cable を動かすとき、 どのサーバーで発生した broadcast も全購読者へ届く必要があります。
その中継を担うのが Redis などの pub/sub バックエンドです。
Rails process A --\ Rails process B --- Redis pub/sub ---> Cable subscribers Rails process C --/
config/cable.yml の Redis 設定は、このために重要です。 ([Ruby on Rails Guides][2])
11.2.4 ブロードキャスト粒度を雑にしない
ゆっくり霊夢 「全員に全部飛ばせば簡単じゃない?」
ゆっくり魔理沙 「それは最初は簡単だが、規模が大きくなるとつらいぜ。」
悪い例:
<%= turbo_stream_from "global_tasks" %>
これだと、全員がすべてのタスク更新を受け取ります。
改善例:
<%= turbo_stream_from [@project, "tasks"] %>
モデル側:
after_create_commit -> {
broadcast_prepend_to [project, "tasks"],
target: "tasks",
partial: "tasks/task_card",
locals: { task: self }
}
こうすると、必要な人だけが必要な更新を受け取る 設計になります。
11.2.5 broadcast頻度もコストになる
たとえばこんな実装は危ないです。
after_update_commit -> { broadcast_replace_to "tasks" }
もし Task が細かく何度も更新されるモデルだと、
大量の replace が飛んでUIもサーバーも重くなります。
見直しの方向はこうです。
- 本当にリアルタイム同期が必要か - どの属性変化で broadcast するか - 行単位で十分か、一覧全体更新が必要か - 連打的更新を減らせないか
11.2.6 Cableを使わない画面までリアルタイム化しない
Hotwireはリアルタイムが得意ですが、全部の画面にCableを入れる必要はありません。
向いている画面:
- 共同編集に近い一覧 - コメントや通知 - ステータス変化が重要なダッシュボード
無理に入れなくていい画面:
- ほぼ単独作業の管理画面 - 更新頻度が低い設定画面 - わざわざ同期しなくても困らない詳細画面
ゆっくり霊夢 「リアルタイムは便利だけど、目的が先なのね。」
ゆっくり魔理沙 「その通りだぜ。 Cableは“使えるから使う”より、“必要だから使う”が大事。」
11.2.7 スケーリングの見方
Action Cable運用で見るべき指標は、たとえばこんな感じです。
- 同時接続数 - 1プロセスあたりのメモリ - broadcast件数 / 秒 - 接続失敗率 - 再接続頻度
HTTPのレスポンスタイムだけ見ていると、Cable側の問題を見落としやすいです。
11.3 パフォーマンス最適化
11.3.1 まずはRailsの基本性能を押さえる
ゆっくり霊夢 「Hotwireの性能って、結局どこを見るの?」
ゆっくり魔理沙 「まず大前提として、普通のRailsアプリとして速いこと だぜ。 HotwireはHTMLを返すから、DB・テンプレート・キャッシュの効率がそのまま効く。」 ([Ruby on Rails Guides][5])
まずやることは地味です。
- N+1を潰す - 不要な一覧全体再描画を避ける - キャッシュできる断片をキャッシュする - Pumaの設定を測りながら調整する
11.3.2 N+1を残したままStreamを増やさない
コントローラ例:
def index @tasks = Task.includes(:assignee, :comments).order(created_at: :desc) end
ビュー例:
<div id="tasks">
<% @tasks.each do |task| %>
<%= render(TaskCardComponent.new(task: task)) %>
<% end %>
</div>
Streamsで replace @task していても、
そのコンポーネント内で関連を毎回引いていたら意味がありません。
def update if @task.update(task_params) @task = Task.includes(:assignee).find(@task.id) respond_to do |format| format.turbo_stream format.html { redirect_to tasks_path } end else ... end end
11.3.3 フラグメントキャッシュを検討する
Railsのキャッシュガイドでは、fragment caching が中心的な戦略として扱われています。 ([Ruby on Rails Guides][5])
たとえば一覧の1行コンポーネントをキャッシュできます。
<!-- app/components/task_card_component.html.erb -->
<% cache task do %>
<section id="<%= dom_id(task) %>" class="task-card">
<h2><%= task.title %></h2>
<p><strong>Status:</strong> <%= task.status %></p>
<p><strong>Due:</strong> <%= task.due_on %></p>
</section>
<% end %>
ただし注意点もあります。
- ユーザーごとに見え方が違う表示は雑に共有キャッシュしない - broadcastで頻繁に差し替える場所はキャッシュ設計を意識する - キャッシュキーの粒度を適切にする
11.3.4 一覧全体replaceを避ける
悪い例:
<%= turbo_stream.replace "tasks" do %>
<%= render partial: "tasks/list", locals: { tasks: @tasks } %>
<% end %>
これだと、
- クエリが増えやすい
- 描画コストが大きい
- スクロール位置が崩れやすい
- DOM再接続が多くなる
という問題があります。
基本は局所更新です。
<%= turbo_stream.prepend "tasks" do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %> <%= turbo_stream.replace @task do %> <%= render(TaskCardComponent.new(task: @task)) %> <% end %> <%= turbo_stream.remove @task %>
11.3.5 Pumaの並列性は“測って決める”
Rails の本番チューニングガイドでも、Puma のスレッド数・ワーカー数は CPU / メモリ / I/O特性を見ながら調整する のが基本です。 ([Ruby on Rails Guides][3])
たとえば:
# config/puma.rb threads ENV.fetch("RAILS_MAX_THREADS", 5), ENV.fetch("RAILS_MAX_THREADS", 5) workers ENV.fetch("WEB_CONCURRENCY", 2) preload_app!
でもこれを固定値で信仰するのは危険です。
- DB待ちが多いなら threads が効きやすい - CPU重い処理が多いなら workers の影響が大きい - メモリが厳しいなら workers を増やしすぎない
11.3.6 開発環境と本番環境の差に気をつける
ゆっくり霊夢 「ローカルだと速いのに本番で遅い、ってよくあるわよね。」
ゆっくり魔理沙 「あるあるだぜ。 Hotwireだと“HTML断片をよく返す”ぶん、テンプレート・DB・WebSocket周りの差が効きやすい。」
とくに差が出やすいのは:
- DBのレイテンシ - reverse proxy / CDN の有無 - Redisとの通信距離 - WebSocket接続数 - キャッシュ有効化の有無
Railsには bin/rails dev:cache もありますが、開発での見え方と本番の挙動は必ずしも一致しません。キャッシュは本番前提で確認するのが大事です。 ([Ruby on Rails Guides][6])
11.3.7 パフォーマンス改善の優先順位
まずはこの順番がおすすめです。
1. N+1を潰す 2. 一覧全体再描画を減らす 3. fragment caching を入れる 4. Puma / インフラ設定を調整する 5. broadcast粒度を見直す
いきなり難しいインフラ最適化より、アプリの無駄を減らす 方が先に効くことが多いです。
11.4 ログと監視
11.4.1 運用で本当に困るのは“見えないこと”
ゆっくり霊夢 「デプロイ後って、壊れたらどうするのが一番大事なの?」
ゆっくり魔理沙 「一番つらいのは、何が起きているかわからないこと だぜ。 だからログと監視は、派手じゃないけどかなり重要だ。」 ([Ruby on Rails Guides][7])
11.4.2 Railsログはまず request id を追えるようにする
Rails ではログタグを使えます。設定ガイドでも config.log_tags が紹介されています。 ([Ruby on Rails Guides][8])
# config/environments/production.rb config.log_tags = [ :request_id ]
あるいは、必要ならユーザー情報やサブドメインも加えられます。
config.log_tags = [ :request_id, ->(req) { "ip=#{req.remote_ip}" } ]
これで、1つのリクエストに関するログを追いやすくなります。
11.4.3 Turbo Streams / Cable関連は専用に見たい
Hotwire運用では、通常のHTTPログに加えて、次も気にしたいです。
- /cable 接続の成功/失敗 - broadcast の頻度 - 想定外に大量の stream 更新 - 特定画面だけ重いレスポンス
Action Cableのログは、Webリクエストとは別の観点で見る価値があります。 「ページは開くのにリアルタイム更新だけ止まっている」みたいな障害があるからです。 ([Ruby on Rails Guides][2])
11.4.4 Railsの error reporter を使う
Rails には Error Reporter があります。公式ガイドでも、例外を外部サービスへ送る標準的な仕組みとして説明されています。 ([Ruby on Rails Guides][9])
イメージとしてはこうです。
Rails.error.report(exception, handled: true, context: { feature: "task_realtime" })
もちろん Sentry などの外部サービスを組み合わせることも多いですが、 本としてはまず「Rails標準でエラー報告の入口がある」と押さえると良いです。 ([Ruby on Rails Guides][9])
11.4.5 カスタム計測も入れられる
Active Support Instrumentation を使うと、アプリ独自のイベントを計測できます。 ([Ruby on Rails Guides][10])
たとえば、重い並び替え処理を計測したいならこうです。
# app/controllers/tasks_controller.rb def reorder ActiveSupport::Notifications.instrument("tasks.reorder") do params[:task_ids].each_with_index do |id, index| Task.where(id: id).update_all(position: index + 1) end end head :ok end
購読側イメージです。
ActiveSupport::Notifications.subscribe("tasks.reorder") do |name, start, finish, id, payload| duration_ms = ((finish - start) * 1000).round(1) Rails.logger.info("[instrumentation] #{name} took #{duration_ms}ms") end
ゆっくり霊夢 「“どこが重いか”を自分で測れるのはいいわね。」
ゆっくり魔理沙 「そうだぜ。 運用って結局、見える化が強い。」
11.4.6 監視で見るべきもの
最低限の監視対象はこんな感じです。
- HTTP 5xx エラー率 - レスポンスタイム - DB接続エラー - WebSocket接続失敗率 - メモリ使用量 - CPU使用率 - queue / job の滞留
Hotwire特有に寄せるなら、さらに:
- Cable接続数 - broadcast件数 - 主要なTurbo Stream更新の失敗 - 画面更新が止まったときの痕跡
を見られると強いです。
11.4.7 ログを読みやすくする工夫
本番ログは、雑だとすぐ読めなくなります。
おすすめ:
- request_id を付ける - 重要イベントだけ structured に近い形で出す - 例外は error reporter へ送る - “成功した通常処理” を喋りすぎない
たとえば並び替えログ:
Rails.logger.info( event: "tasks.reorder", actor_id: current_user.id, task_ids: params[:task_ids] )
実際のフォーマットはロガー次第ですが、後で検索しやすい形 を意識すると運用が楽になります。
11.4.8 障害調査の順番を決めておく
ゆっくり霊夢 「もし“リアルタイム更新だけ止まった”みたいな問い合わせが来たら、どこから見るのがいいの?」
ゆっくり魔理沙 「こういう順番がおすすめだぜ。」
1. HTTP自体は正常か 2. /cable の接続は成功しているか 3. broadcast は発生しているか 4. Redis / backend は正常か 5. 対象画面で turbo_stream_from が正しく張られているか 6. stream対象idの不一致がないか
つまり、アプリ・Cable・バックエンド・DOM更新 を順番に切り分けるんだ。
この章のまとめ
ゆっくり霊夢 「この章、かなり実務の匂いがしたわね。 “動くアプリ”を“運用できるアプリ”にする視点が入った感じ。」
ゆっくり魔理沙 「それが狙いだぜ。 この章のポイントをまとめるとこうなる。」
- Hotwireアプリの本番構成は、まず Rails / DB / Cable / proxy の役割を整理するのが大事 - 小規模では Action Cable を同居させてもよいが、接続数が増えると分離構成を検討する - Action Cable は Redis などの pub/sub バックエンドと組み合わせて複数プロセスへ広げる - broadcast は雑に全体配信せず、必要な購読者へ絞る - パフォーマンス改善は、まず N+1 / 全体再描画 / キャッシュから見る - Puma の設定は固定の正解を信じず、測りながら調整する - ログでは request_id や Cable系の挙動を追いやすくする - Rails error reporter と instrumentation を使うと、障害調査と可観測性がかなり良くなる
練習問題
問1
小規模なHotwireアプリで、最初からWebサーバーとAction Cableサーバーを完全分離しなくてもよい理由は何ですか。
問2
Action Cable を複数プロセス・複数サーバーへ広げるとき、Redis のような pub/sub バックエンドが必要になるのはなぜですか。
問3
turbo_stream_from [@project, "tasks"] のようにストリームを細かく分ける利点は何ですか。
問4
Hotwireアプリのパフォーマンス改善で、いきなりインフラ調整の前に N+1 や一覧全体再描画を見直したほうがよいのはなぜですか。
問5
“リアルタイム更新が止まった”とき、切り分けの順番としてどんな観点を見るべきですか。2つ以上挙げてください。
章末ミニコラム: Hotwireの運用は“普通のRails力”がかなり効く
ゆっくり霊夢 「Hotwireって新しめの技術だから、運用も特殊なのかなと思ってたわ。」
ゆっくり魔理沙 「そこが面白いところで、半分は新しくて、半分はかなりRailsの王道なんだぜ。」
Hotwire運用で効くものは、意外と地味です。
- 安定したPuma設定 - N+1を潰す - キャッシュを使う - request_idで追う - エラーを見逃さない
つまり、“普通のRailsをちゃんと運用する力” がそのままかなり効きます。 そのうえで、Action Cable と Turbo Streams のリアルタイム性を追加で考える感じです。 ([Ruby on Rails Guides][2])
ゆっくり魔理沙 「Hotwireは魔法っぽく見えるけど、本番ではちゃんと地に足のついた運用が勝つんだぜ。」
Chapter 12: Hotwireの限界と使い分け
はじめに
ゆっくり霊夢 「ついに最終章ね。ここまで読むと、“もうHotwireで全部いけるのでは?”って気持ちにもなってくるわ。」
ゆっくり魔理沙 「そこが最後の大事なポイントだぜ。 Hotwireはかなり強いが、万能ではない。そして実務では“何で作れるか”より、“何で作ると長く幸せか”が大事なんだ。」
ゆっくり霊夢 「つまりこの章は、Hotwireを持ち上げすぎず、ちゃんと限界も見る章なのね。」
ゆっくり魔理沙 「そうだぜ。 Hotwireを本当に使いこなすなら、向いている場所と向いていない場所を見極める必要がある。」
この章では、次の4つを扱います。
- 12.1 React/Vueと比較
- 12.2 向いているプロダクト
- 12.3 向いていないケース
- 12.4 ハイブリッド構成
12.1 React/Vueと比較
12.1.1 まず結論: 戦っているようで、実は役割が違う
ゆっくり霊夢 「HotwireとReactって、やっぱりライバルなの?」
ゆっくり魔理沙 「表面的にはそう見えることもあるが、実際には得意な問題が違うんだぜ。」
ざっくり比較するとこうです。
Hotwire: - サーバーHTML中心 - Railsとの一体感が強い - CRUDやフォームや管理画面が得意 - JSを最小限に抑えやすい React / Vue: - クライアント状態中心 - 複雑なインタラクションが得意 - UI部品の再利用が強い - フロントエンド主導の設計に向く
12.1.2 描画責務の違い
一番大きな違いは、どこが表示を主導するか です。
Hotwire的な発想
# app/controllers/tasks_controller.rb def index @tasks = Task.order(created_at: :desc) end
<!-- app/views/tasks/index.html.erb -->
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
ここでは、
サーバーがHTMLを作る ↓ ブラウザはそれを表示する ↓ 必要ならTurboで差し替える
という流れです。
React的な発想
import { useEffect, useState } from "react" export default function TasksPage() { const [tasks, setTasks] = useState([]) useEffect(() => { fetch("/api/tasks") .then((response) => response.json()) .then((data) => setTasks(data)) }, []) return ( <div> {tasks.map((task) => ( <section key={task.id}> <h2>{task.title}</h2> </section> ))} </div> ) }
ここでは、
サーバーはJSONを返す ↓ クライアントが状態を持つ ↓ クライアントがUIを描画する
という流れです。
12.1.3 状態管理の重さが違う
ゆっくり霊夢 「Reactって結局“状態管理”の話が大きい印象あるわ。」
ゆっくり魔理沙 「そこが大きな違いだぜ。 Hotwireは、状態をなるべくサーバーへ戻す。React/Vueは、状態をクライアントで持つ前提 が強い。」
たとえば、一覧 + 詳細 + 編集中状態を考えます。
Hotwire寄り
- 現在の表示はサーバーHTMLに乗っている - 編集フォームもサーバーが返す - クライアント状態は最小
React/Vue寄り
- 選択中task - 編集中かどうか - 入力中フォーム値 - ローディング状態 - エラー状態
これをクライアント側で持つことが多いです。
つまり、
複雑な状態管理が必要なら React/Vue が自然 状態をできるだけ持ちたくないなら Hotwire が自然
です。
12.1.4 UI部品化の強さはReact/Vueが有利なことが多い
React/Vueの強さは、部品化された複雑UIを大量に組むこと にあります。
たとえば:
- 高機能データグリッド - 複雑なフィルタパネル - ネストしたインタラクティブフォーム - クライアントだけで完結するリッチUI
はReact/Vueのほうが自然になりやすいです。
一方でHotwireは、
- HTML断片を返す - 必要なところだけStimulusで動かす
という思想なので、 巨大なクライアントUIの構築そのもの は得意分野ではありません。
12.1.5 開発体験の違い
比較すると、開発の手触りもかなり違います。
Hotwire: - Railsの延長で書ける - ERB / partial / helper / ViewComponent が中心 - バックエンド寄りエンジニアに馴染みやすい React / Vue: - フロントエンド中心の設計になりやすい - コンポーネント指向が強い - npm / bundler / 状態管理ライブラリとの付き合いが増える
ゆっくり霊夢 「“どちらが上か”じゃなくて、“どちらの複雑さを引き受けるか”が違うのね。」
ゆっくり魔理沙 「その理解が一番大事だぜ。」
12.1.6 比較表で整理する
+----------------------+-------------------------+---------------------------+ | 観点 | Hotwire | React / Vue | +----------------------+-------------------------+---------------------------+ | 描画の主役 | サーバーHTML | クライアントUI | | 通信 | HTML / turbo-stream | JSON / GraphQL など | | 状態管理 | 少なめに寄せやすい | 多くなりやすい | | Railsとの相性 | とても良い | 分離設計が増えやすい | | CRUD/フォーム | とても得意 | できるがやや重いことも | | 複雑UI | 苦手になりやすい | 得意 | | 開発チーム構成 | フルスタック向き | FE/BE分業向き | +----------------------+-------------------------+---------------------------+
12.1.7 “Reactを捨てる”ではなく“Reactを必要な所に使う”
ゆっくり霊夢 「Hotwireを使うなら、Reactはもう敵って感じでもないのね。」
ゆっくり魔理沙 「むしろ逆だぜ。 Hotwireを理解すると、“Reactをどこにだけ使うべきか”がはっきりしてくる。」
つまり、
全部React
でもなく、
全部Hotwire
でもなく、
大半はHotwire 本当に必要な複雑UIだけReact/Vue
という判断がしやすくなるわけです。
12.2 向いているプロダクト
12.2.1 まず“普通のWebアプリ”に強い
ゆっくり霊夢 「具体的に、どんなプロダクトだとHotwireが刺さるの?」
ゆっくり魔理沙 「まず一番は、フォーム中心・CRUD中心の普通のWebアプリ だぜ。」
たとえば:
- 管理画面 - 社内業務システム - CMS - タスク管理ツール - 予約システム - 問い合わせ管理 - 営業支援ツール
こういうアプリでは、Hotwireの強みがかなりそのまま出ます。
12.2.2 フォーム主体のアプリ
フォームが多いアプリでは、Railsの得意技がそのまま活きます。
<%= form_with model: @task do |form| %> <%= form.text_field :title %> <%= form.select :status, Task::STATUSES %> <%= form.submit %> <% end %>
そこにHotwireを足すと、
- 保存後に一覧へ即反映 - エラー時にその場再描画 - モーダルフォーム - インライン編集
が自然に入ります。
つまり、
“元々Railsが得意だった領域” を “今っぽいUXで磨く”
のがHotwireの得意パターンです。
12.2.3 サーバー側に表示ロジックを寄せたいプロダクト
たとえばこんな事情があるとHotwireは向いています。
- APIをわざわざ分けたくない - 表示ロジックをRailsに寄せたい - バックエンドと画面を同じチームで触る - ビューをERB / Componentで管理したい
こういうケースでは、Hotwireはかなり筋が良いです。
12.2.4 SEOや初期表示も大事なアプリ
HotwireはサーバーHTML中心なので、初期表示を素直に出しやすい です。
向いている例:
- ログイン後のアプリ本体 - 一部公開ページを持つサービス - 管理画面 + 一般公開ページの同居
もちろん超高度なフロント最適化が必要な公開サービスでは別判断もありますが、 少なくとも “最初から全部CSRにしなくていい” のは大きいです。
12.2.5 フルスタック少人数チーム
ゆっくり霊夢 「人の問題もあるわよね。」
ゆっくり魔理沙 「そこはかなり大きいぜ。 Hotwireは、少人数でRailsを一気通貫で触るチーム と相性がいい。」
たとえば:
- Railsエンジニア2〜5人 - フロント専任はいない or 少ない - まず速く作って改善したい - API分離より一体開発の方が速い
この条件なら、Hotwireはかなり強い選択肢です。
12.2.6 向いているプロダクトの特徴まとめ
- CRUDが多い - フォームが多い - 一覧/詳細/編集が中心 - リアルタイムは“ほどほど”で十分 - クライアント状態を増やしたくない - Railsで速く開発したい
12.3 向いていないケース
12.3.1 “クライアントが主役”のUIは苦しくなりやすい
ゆっくり霊夢 「じゃあ逆に、Hotwireで無理しないほうがいいのは?」
ゆっくり魔理沙 「一番わかりやすいのは、クライアント側で大量の状態を持つUI だぜ。」
たとえば:
- 高機能なスプレッドシート - デザインツール - ノーコードエディタ - 複雑なドラッグ&ドロップビルダー - ブラウザ内だけで完結する図形編集
こういうのは、Hotwireだとかなり苦しくなりやすいです。
12.3.2 オフラインやローカル状態が重要なアプリ
Hotwireは基本的に、
サーバーへ取りにいく ↓ HTMLを返す ↓ 更新する
という思想です。
なので、次のような要件には向きにくいです。
- オフライン前提 - ネットワーク不安定環境で長時間使う - ブラウザローカルに複雑な作業状態を持つ - サーバー通信なしで大量編集したい
この場合は、クライアント状態管理が強いフレームワークの方が自然です。
12.3.3 APIを複数クライアントで共有したいケース
たとえば:
- Web - iOS - Android - 外部パートナー向けAPI
を同じバックエンドで支えたいなら、 最初から JSON API / GraphQL中心 に設計した方が筋がよいことがあります。
HotwireはHTMLを返す前提が強いので、
WebはHotwire モバイルは別API
という二本立てになることがあります。
それが悪いわけではありませんが、 最初からマルチクライアント共通APIが主役 なら、別構成の方が自然です。
12.3.4 フロントエンド専門チームが大きい場合
ゆっくり霊夢 「組織の都合もありそうね。」
ゆっくり魔理沙 「かなりあるぜ。 フロントエンド専任チームが大きく、設計も完全分業なら、Hotwireのメリットが薄まることがある。」
たとえば:
- デザインシステムチームがある - フロントエンド専任が複数いる - Webフロントを独立アプリとして管理したい - SSR / CSR / BFF まで含めてFE主導で設計したい
この場合、React/Vue/Nuxt/Next などの構成の方が組織構造に合うことがあります。
12.3.5 無理にFrames/Streamsで頑張りすぎる兆候
次のような状態が増えてきたら、Hotwireで無理しているサインかもしれません。
- フレームのネストが深すぎる - Streamテンプレートが巨大 - Stimulus controller が巨大 - Turboを避ける例外が増える - クライアント側で持ちたい状態がどんどん増えている
コードでいうと、こういう巨大controllerは危険信号です。
// app/javascript/controllers/editor_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "toolbar", "canvas", "layers", "selection", "history", "preview", "zoom", "export" ] connect() {} selectItem() {} moveItem() {} resizeItem() {} undo() {} redo() {} zoomIn() {} zoomOut() {} export() {} saveDraft() {} syncState() {} }
ゆっくり霊夢 「これはもう“軽いちょい足しJS”の域を超えてるわね。」
ゆっくり魔理沙 「そうだぜ。 Stimulusがここまで太り出したら、設計を見直した方がいい。」
12.3.6 向いていないケースの特徴まとめ
- クライアント状態が重い - オフライン前提 - 高度なブラウザ内編集が主役 - マルチクライアント共通APIが中心 - FE専任チーム主導で作る
12.4 ハイブリッド構成
12.4.1 実務では“全部どちらか”にならないことが多い
ゆっくり霊夢 「ここまで聞くと、結局ハイブリッドが現実的に見えてくるわね。」
ゆっくり魔理沙 「その通りだぜ。 実務では、大半はHotwire、一部だけReact/Vue がかなり現実的だ。」
12.4.2 一番おすすめのハイブリッド
まず王道はこれです。
- 画面全体はRails + Hotwire - 特定の複雑UIだけReact/Vue
たとえば:
- 一覧/詳細/CRUDはHotwire - 高機能グラフエディタだけReact - 複雑なフィルタビルダーだけVue - 画像トリミングUIだけReact
この構成だと、全体の複雑さを抑えつつ、本当に難しいところだけ専用ツールを使えます。
12.4.3 埋め込み型コンポーネントの例
たとえば、タスク詳細ページの一部にReactウィジェットを埋め込むイメージです。
<!-- app/views/tasks/show.html.erb --> <h1><%= @task.title %></h1> <p><%= @task.description %></p> <div id="task-chart-root" data-task-id="<%= @task.id %>" ></div>
React側イメージ:
import { createRoot } from "react-dom/client" import TaskChart from "./TaskChart" document.addEventListener("turbo:load", () => { const rootElement = document.getElementById("task-chart-root") if (!rootElement) return const root = createRoot(rootElement) root.render(<TaskChart taskId={rootElement.dataset.taskId} />) })
このように、Hotwireの画面の中に一部だけクライアントUIを置く ことができます。
12.4.4 ページ単位で分ける構成
もうひとつのハイブリッドは、画面単位で分ける やり方です。
- 管理画面はHotwire - エンドユーザー向け一部機能はReact SPA
たとえば:
/admin/... → Rails + Hotwire /app/designer/... → React /app/dashboard/... → Hotwire
この分け方なら、難しい画面だけ別アプリ的に育てやすいです。
12.4.5 APIの切り分けも必要になる
ハイブリッドにすると、同じRailsアプリの中でも返すものが分かれてきます。
# config/routes.rb Rails.application.routes.draw do resources :tasks namespace :api do resources :tasks, only: [:index, :show, :update] end end
Hotwire画面は普通のHTMLを使い、 React/Vue側は API を使う、という形です。
ゆっくり霊夢 「ちょっと複雑にはなるけど、全部を分離するよりは軽そうね。」
ゆっくり魔理沙 「そうだぜ。 “必要な部分だけAPI化する” のはかなり現実的な落としどころだ。」
12.4.6 StimulusとReactの境界を明確にする
ハイブリッドで気をつけたいのは、StimulusとReactの責務がぶつからないこと です。
悪い例:
- 同じDOMをStimulusもReactも触る - Turboの更新でReact領域が壊れる - Reactが管理するDOMにStimulusでイベントを足す
おすすめはこうです。
- Reactが支配するrootを明確にする - その内側はReactに任せる - その外側はHotwire / Stimulusに任せる
つまり、DOMの支配境界をはっきりさせることが大事です。
12.4.7 ハイブリッド構成の判断基準
ハイブリッドが向いているのは、たとえば次です。
- 大半はCRUDだが、一部だけ極端に複雑 - Rails資産を活かしたい - でも一部UIだけはフロント主導で作りたい - 全面SPAにするほどではない
逆に、
- ほぼ全部が複雑なクライアントUI
なら、最初からReact/Vue中心の方が素直です。
12.4.8 最終的な考え方
ゆっくり霊夢 「結局、技術選定って“好きな技術を信じる”じゃなくて、“複雑さをどこに置くか選ぶ”ってことなのね。」
ゆっくり魔理沙 「まさにそれだぜ。 Hotwireは、複雑さを必要以上にクライアントへ持ち込まないための強い選択肢なんだ。」
そして必要なら、
- 一部だけReact/Vue - 一部だけAPI - 一部だけ重いフロント構成
を足せばいい。 それが、かなり実務的な答えです。
この章のまとめ
ゆっくり霊夢 「最終章らしく、かなり整理されたわ。 “Hotwireが強い”だけじゃなくて、“どこでやめるか”まで見えた感じ。」
ゆっくり魔理沙 「それが一番大事だぜ。 この章のポイントをまとめるとこうなる。」
- HotwireはサーバーHTML中心、React/Vueはクライアント状態中心という違いが大きい - CRUD、フォーム、管理画面、社内ツールのようなWebアプリではHotwireが非常に強い - クライアント状態が重いUIやオフライン前提アプリではHotwireは苦しくなりやすい - マルチクライアント共通API中心やFE専任大規模チームでは、React/Vue系構成が自然なことが多い - 実務では “大半はHotwire、一部だけReact/Vue” のハイブリッド構成がかなり有効 - 技術選定の本質は、どこに複雑さを置くかを決めることにある
練習問題
問1
Hotwire と React/Vue の最も大きな設計上の違いは何ですか。
問2
Hotwire が向いているプロダクトの特徴を2つ以上挙げてください。
問3
Hotwire が向いていないケースとして、クライアント状態が重いUIにはどんな例がありますか。2つ以上挙げてください。
問4
ハイブリッド構成では、なぜ React が管理するDOM領域と Stimulus/Turbo が管理する領域の境界を明確にしたほうがよいのでしょうか。
問5
“全部Hotwire” や “全部React” に決め打ちしないほうがよい理由を説明してください。
章末ミニコラム: Hotwireを学ぶ価値は“選択肢が増えること”にある
ゆっくり霊夢 「最終的に思ったのは、Hotwireって“Reactの代わり”というより、“Railsで戦うときの武器が増える”って感じね。」
ゆっくり魔理沙 「それがかなり本質だぜ。」
Hotwireを学ぶ価値は、単に新しい技術を覚えることではありません。
- どこまでサーバーHTMLでいけるか判断できる - クライアント状態管理が本当に必要か見極められる - React/Vueを使う場所を絞れる - Railsアプリの設計自由度が上がる
つまり、Hotwireを知ることで、
“なんでもSPAにする”
以外の現実的な選択肢を持てるようになります。
ゆっくり魔理沙 「技術の強さって、1つを信仰することじゃなくて、ちゃんと使い分けられることなんだぜ。」
Appendix A: Turbo / Stimulus チートシート
はじめに
ゆっくり霊夢 「巻末らしく、さっと見返せるやつが欲しいわね。」
ゆっくり魔理沙 「そうだぜ。 ここは“説明を読む章”というより、実装中に横へ置いておくメモ みたいに使える形にする。」
A.1 Turbo Drive チートシート
基本読み込み
// app/javascript/application.js import "@hotwired/turbo-rails" import "controllers"
通常リンク
<%= link_to "Show", task_path(task) %>
Turboを無効にする
<%= link_to "Normal visit", task_path(task), data: { turbo: false } %>
<%= form_with model: @task, data: { turbo: false } do |form| %>
...
<% end %>
よく使うイベント
document.addEventListener("turbo:load", () => { console.log("loaded") }) document.addEventListener("turbo:before-visit", (event) => { console.log("before visit", event.detail.url) }) document.addEventListener("turbo:visit", (event) => { console.log("visit", event.detail.url) }) document.addEventListener("turbo:submit-start", (event) => { console.log("submit start", event.target) }) document.addEventListener("turbo:submit-end", (event) => { console.log("submit end", event.detail) })
ページ全体の再読み込みを要求する
<%= turbo_page_requires_reload %>
A.2 Turbo Frames チートシート
フレームを定義する
<%= turbo_frame_tag "task_details" do %> <p>Select a task</p> <% end %>
フレームを更新するリンク
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
レスポンス側でも同じフレームidが必要
<%= turbo_frame_tag "task_details" do %> <h2><%= @task.title %></h2> <% end %>
フレームの外へ遷移する
<%= link_to "Back to tasks", tasks_path, data: { turbo_frame: "_top" } %>
モーダル用の空フレーム
<!-- layout --> <%= turbo_frame_tag "modal" %>
<%= link_to "New task", new_task_path, data: { turbo_frame: "modal" } %>
A.3 Turbo Streams チートシート
一覧領域
<div id="tasks">
<% @tasks.each do |task| %>
<%= render "task_card", task: task %>
<% end %>
</div>
append
<%= turbo_stream.append "tasks", partial: "tasks/task_card", locals: { task: @task } %>
prepend
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
replace
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
update
<%= turbo_stream.update "flash" do %> <%= render "shared/flash", notice: "Saved!" %> <% end %>
remove
<%= turbo_stream.remove @task %>
1レスポンスで複数更新
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace "task_form", partial: "tasks/form", locals: { task: Task.new } %>
<%= turbo_stream.update "flash", "Task created." %>
controller側の基本形
def create @task = Task.new(task_params) respond_to do |format| if @task.save format.turbo_stream format.html { redirect_to tasks_path, notice: "Task was successfully created." } else format.html { render :new, status: :unprocessable_entity } end end end
購読する
<%= turbo_stream_from "tasks" %>
broadcastする
after_create_commit -> {
broadcast_prepend_to "tasks",
target: "tasks",
partial: "tasks/task_card",
locals: { task: self }
}
A.4 Stimulus チートシート
基本形
// app/javascript/controllers/hello_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { console.log("connected") } }
<div data-controller="hello"></div>
action
<button data-action="click->hello#greet">Hello</button>
greet() { alert("Hello") }
targets
static targets = ["input", "output"]
<input data-hello-target="input"> <p data-hello-target="output"></p>
this.inputTarget.value this.outputTarget.textContent = "Updated"
values
static values = { count: Number }
<div data-controller="counter" data-counter-count-value="3"></div>
this.countValue this.countValue = 10
classes
static classes = ["active"]
<div data-controller="menu" data-menu-active-class="is-active"></div>
this.element.classList.add(this.activeClass)
複数targets
this.itemTargets.forEach((item) => { console.log(item.textContent) })
よく使うイベント
data-action=" click->controller#method input->controller#method submit->controller#method change->controller#method "
A.5 Turbo × Stimulus 使い分け早見表
ゆっくり霊夢 「結局どっち使うか迷うこと多いのよね。」
ゆっくり魔理沙 「困ったらこれで考えるといいぜ。」
データ保存後に一覧へ1件追加したい → Turbo Streams 一覧の一部だけ詳細へ差し替えたい → Turbo Frames ページ遷移を軽快にしたい → Turbo Drive 文字数カウントを出したい → Stimulus モーダル開閉の細かい制御をしたい → Stimulus 送信中にボタン文言を変えたい → Stimulus 複数ユーザーへ更新を同期したい → Turbo Streams + Action Cable
Appendix B: よくあるエラーと対処法
B.1 「クリックしてもTurbo Frame更新にならない」
ゆっくり霊夢 「Framesが効いてるはずなのに、普通に全ページ遷移しちゃうことあるわ。」
ゆっくり魔理沙 「まず一番多いのはこれだぜ。」
原因1: data-turbo-frame が付いていない
<%= link_to task.title, task_path(task) %>
修正:
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
原因2: レスポンス側に同じidのフレームがない
間違い:
<%= turbo_frame_tag "details" do %> ... <% end %>
正しい:
<%= turbo_frame_tag "task_details" do %> ... <% end %>
原因3: Turbo自体が読み込まれていない
import "@hotwired/turbo-rails"
が app/javascript/application.js にあるか確認します。
B.2 「Turbo Streamが効かずHTML遷移になる」
原因1: respond_to がない、または format.turbo_stream がない
def create @task = Task.new(task_params) respond_to do |format| if @task.save format.turbo_stream format.html { redirect_to tasks_path } else format.html { render :new, status: :unprocessable_entity } end end end
原因2: create.turbo_stream.erb などのファイル名が違う
正しい例:
app/views/tasks/create.turbo_stream.erb app/views/tasks/update.turbo_stream.erb app/views/tasks/destroy.turbo_stream.erb
原因3: ターゲットidが存在しない
<div id="tasks"></div>
に対して:
<%= turbo_stream.append "tasks", ... %>
となっているか確認します。
B.3 「同じタスクが2回追加される」
ゆっくり霊夢 「これ、地味にハマるのよね。」
ゆっくり魔理沙 「かなりありがちだぜ。」
原因: controllerのTurbo Stream応答とbroadcastを両方やっている
# controller
format.turbo_stream
# model after_create_commit -> { broadcast_prepend_to "tasks" }
両方で同じ追加をしていると二重になります。
対処
- 自分向け更新は controller - 他人向け同期は broadcast
のように方針を分ける。
B.4 「モーダルの中に一覧画面が入ってしまう」
原因: フレーム内リンクが _top になっていない
間違い:
<%= link_to "Back", tasks_path %>
モーダル内ではこれが modal フレーム更新になってしまうことがあります。
修正:
<%= link_to "Back", tasks_path, data: { turbo_frame: "_top" } %>
B.5 「Stimulus controller が動かない」
原因1: ファイル名と data-controller 名が一致していない
hello_controller.js ↓ data-controller="hello"
length_counter_controller.js ↓ data-controller="length-counter"
原因2: controllers/index.js に登録されていない
import { application } from "controllers/application" import HelloController from "./hello_controller" application.register("hello", HelloController)
原因3: target名が一致していない
JS:
static targets = ["output"]
HTML:
<p data-hello-target="output"></p>
B.6 「Turboでページ遷移後、JS初期化が効かない」
原因: DOMContentLoaded に依存している
悪い例:
document.addEventListener("DOMContentLoaded", () => { console.log("only once") })
修正:
document.addEventListener("turbo:load", () => { console.log("after every Turbo visit") })
B.7 「イベントが二重に発火する」
原因: turbo:load のたびに addEventListener を足し続けている
悪い例:
document.addEventListener("turbo:load", () => { const button = document.getElementById("danger-button") if (!button) return button.addEventListener("click", () => { alert("clicked") }) })
対処1: onclick にする
document.addEventListener("turbo:load", () => { const button = document.getElementById("danger-button") if (!button) return button.onclick = () => { alert("clicked") } })
対処2: Stimulusへ移す
export default class extends Controller { click() { alert("clicked") } }
<button data-controller="button" data-action="click->button#click"> Click </button>
B.8 「Action Cableで同期されない」
チェックポイント
- /cable が開いているか - turbo_stream_from があるか - broadcast_* が呼ばれているか - config/cable.yml が正しいか - Redis接続先が正しいか
Appendix C: Hotwireデバッグガイド
C.1 デバッグの基本姿勢
ゆっくり霊夢 「Hotwireって魔法っぽく動くぶん、壊れたときに何を見ればいいか迷いがちよね。」
ゆっくり魔理沙 「だから順番が大事なんだぜ。 Hotwireのデバッグは、“勘”じゃなくて“層ごとに切り分ける”が基本だ。」
おすすめの順番:
1. HTMLは正しいか 2. Turbo/Stimulusは読み込まれているか 3. リクエスト形式は想定どおりか 4. レスポンス内容は想定どおりか 5. DOM更新対象は存在するか 6. Cable/Redisは正常か
C.2 まずHTMLを見る
最初に確認するもの:
- id が付いているか - data-controller が正しいか - data-action が正しいか - data-xxx-target が正しいか - turbo-frame の id が合っているか
例:
<div id="tasks"> ... </div> <%= turbo_frame_tag "task_details" do %> ... <% end %> <div data-controller="counter"> <button data-action="click->counter#increment">+1</button> </div>
ゆっくり霊夢 「たしかに“まずHTMLを見る”だけで解けること多いわ。」
ゆっくり魔理沙 「本当に多いぜ。 HotwireはHTML中心だから、最初の確認地点もHTMLなんだ。」
C.3 Turboイベントをログに出す
document.addEventListener("turbo:click", (event) => { console.log("turbo:click", event.target) }) document.addEventListener("turbo:before-visit", (event) => { console.log("turbo:before-visit", event.detail.url) }) document.addEventListener("turbo:visit", (event) => { console.log("turbo:visit", event.detail.url) }) document.addEventListener("turbo:load", () => { console.log("turbo:load") }) document.addEventListener("turbo:submit-start", (event) => { console.log("turbo:submit-start", event.target) }) document.addEventListener("turbo:submit-end", (event) => { console.log("turbo:submit-end", event.detail) })
これで、
- Turboがイベントを拾っているか - submitが発火しているか - visitしているか
が見えます。
C.4 Stimulusの接続確認
各controllerにまずこれを入れる。
connect() { console.log("connected", this.element) }
必要なら:
disconnect() { console.log("disconnected", this.element) }
これで、
- controllerがそもそも接続されているか - Turbo更新後に再接続されているか
が見えます。
C.5 サーバーログを見る
Rails側では、アクションに入っているかを見るだけでも大きいです。
def create Rails.logger.info("TasksController#create called") ... end
また、受け取ったformatも確認できます。
def create Rails.logger.info("request.format = #{request.format}") ... end
これで、
text/vnd.turbo-stream.html
なのか、
text/html
なのかが見えます。
C.6 レスポンス本文を見る
Turbo Streamが効かないときは、レスポンスの中身を確認します。
期待する形:
<turbo-stream action="append" target="tasks"> <template> ... </template> </turbo-stream>
もし普通のHTMLを返していたら、format.turbo_stream 側へ入っていない可能性があります。
C.7 一時的にTurboを切る
ゆっくり霊夢 「Turboが悪いのか、HTML自体が悪いのか、切り分けたいことあるわ。」
ゆっくり魔理沙 「そのときはこれだぜ。」
<%= link_to "Open", task_path(task), data: { turbo: false } %>
<%= form_with model: @task, data: { turbo: false } do |form| %>
...
<% end %>
これで通常のブラウザ挙動に戻せるので、
- Turbo由来の問題か - そもそもRails画面自体が壊れているか
を分けられます。
C.8 Cableのデバッグ
一覧画面:
<%= turbo_stream_from "tasks" %>
モデル:
after_create_commit -> {
broadcast_prepend_to "tasks",
target: "tasks",
partial: "tasks/task_card",
locals: { task: self }
}
ここで同期されない場合は:
1. ブラウザのNetworkで /cable を見る 2. WebSocket接続成功しているか 3. サーバーログでbroadcastが出ているか 4. 購読ストリーム名が一致しているか
C.9 よく使う“とりあえず確認コード”
DOM存在確認
console.log(document.getElementById("tasks")) console.log(document.querySelector('[data-controller="counter"]'))
current HTML確認
console.log(document.body.innerHTML)
Stimulus target確認
connect() { console.log(this.hasOutputTarget) console.log(this.outputTarget) }
request format確認
Rails.logger.info("Accept header: #{request.headers['Accept']}") Rails.logger.info("request.format: #{request.format}")
C.10 デバッグの考え方まとめ
動かない ↓ HTMLを見る ↓ Turbo/Stimulusのイベントログを見る ↓ サーバーログでformatとactionを見る ↓ レスポンス本文を見る ↓ 必要ならTurboを切る ↓ Cableなら接続とbroadcastを見る
Appendix D: 参考リソース
D.1 まず何を読み返すべきか
ゆっくり霊夢 「最後に、どこを見返せばいいか整理したいわ。」
ゆっくり魔理沙 「本の読後に迷わないように、目的別に置いておこう。」
D.2 学習の順番おすすめ
1. まず土台
- RailsのCRUD - REST - partial - form_with
2. Hotwireの中心
- Turbo Drive - Turbo Frames - Turbo Streams
3. 補助的なJS
- Stimulus controller / target / action - connect / disconnect
4. 実務化
- ViewComponent - Form Object / Service Object - Action Cable - テスト - 運用
D.3 困ったときの見返し先
ページ遷移がおかしい
→ Turbo Drive → turbo:load → data-turbo="false"
一部分更新が効かない
→ Turbo Frames → frame id 一致確認 → _top の確認
追加・更新・削除が即時反映されない
→ Turbo Streams → create/update/destroy.turbo_stream.erb → target id の確認
JSの細かい動きが効かない
→ Stimulus → data-controller → data-action → targets → connect ログ
他ユーザー同期が効かない
→ turbo_stream_from → broadcast_* → /cable → Redis
D.4 実装パターン再確認メモ
一覧へ追加
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
行を差し替え
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
行を削除
<%= turbo_stream.remove @task %>
フレーム更新
<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
Stimulus action
<button data-action="click->counter#increment">+1</button>
D.5 学習を深めるときのテーマ
ゆっくり霊夢 「この本を読み終わったあと、次に深掘りするなら何がいいの?」
ゆっくり魔理沙 「おすすめはこのへんだぜ。」
- ViewComponentを使った大規模ビュー設計 - Turbo Streams + 認可 - Action Cableの本番運用 - Stimulusの責務分割 - Railsのキャッシュ戦略 - システムテストの安定化 - 一部React/Vueを混ぜるハイブリッド構成
D.6 付録の使い方
A: 実装中に横へ置く早見表 B: ハマったときの原因切り分け C: 何から調べるか迷ったときの手順書 D: 学習の再スタート地点
ゆっくり霊夢 「付録って後回しにされがちだけど、実は一番実戦で使う部分かもしれないわね。」
ゆっくり魔理沙 「そうだぜ。 本編で理解して、付録で手を動かす。これが一番強い。」
Appendices 全体まとめ
ゆっくり霊夢 「これで巻末まで揃ったわね。かなり“使える本”って感じになってきたわ。」
ゆっくり魔理沙 「そうだぜ。 付録の役割は、“読み終わったあとに現場で使えること”だからな。」
この付録のポイントをまとめるとこうです。
- Turbo / Stimulus の最小実装をすぐ引ける - ありがちなミスをパターンで潰せる - Hotwireのデバッグ手順を順番で覚えられる - 本編の知識を実装・調査・復習へつなげられる
章末ミニコラム: Hotwireは“覚える”より“引ける”が強い
ゆっくり霊夢 「正直、Turbo Streamの書き方とかStimulusのdata属性とか、全部は覚えきれない気もするわ。」
ゆっくり魔理沙 「それでいいんだぜ。 実務で強いのは、“全部暗記してる人”より、“必要なときにすぐ引ける人”だ。」
Hotwireは特に、
- フレーム名の一致 - target id の一致 - data-controller / data-action / target の対応 - request format の確認
みたいな、小さい規則の積み重ね で動いています。
だからこそ、チートシートとデバッグ手順が役に立ちます。
ゆっくり魔理沙 「Hotwireは魔法じゃない。 でも、規則をちゃんと引けるようになると、かなり強い武器になるんだぜ。」
