Turbo(Hotwire) は、SPA のような操作感を目指しつつ、基本は サーバーレンダリングされた HTML を送って画面を更新する仕組みです。
JSON を返してブラウザ側で UI を組み立てるのではなく、完成した HTML を返し、Turbo が差分更新する。これが中核です。
🧩 Turbo と SPA の違い(契約が逆転する)
SPA(React/Vue)
- サーバー: JSON(データ)
- クライアント: UI 構築 + ルーティング + 状態管理 を抱える
Turbo
- サーバー: HTML(UI)
- クライアント: 遷移と DOM 反映 を担う(UI 構築はしない)
Turbo Drive はレスポンスの <body> を使って現在の <body> を更新し、<head> もマージします。

🚗 A. Turbo Drive(ページ遷移の土台)
Turbo Drive は link click と form submit を監視し、裏で fetch して フルリロードなしで更新します。
Rails 側は「普通に書く」でだいたい動く
Rails では Turbo が同梱されており、Turbo Frames/Streams も含めガイドに統合されています。
✅ ここだけは必須: 303 と 422
- 変更系フォーム(POST 等)の成功は 303 リダイレクト
- バリデーション失敗は 422 で返す
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.html { redirect_to @post, status: :see_other } # 303
format.turbo_stream
else
format.html { render :new, status: :unprocessable_entity } # 422
format.turbo_stream { render :new, status: :unprocessable_entity }
end
end
end🧠 $(document).ready() が効かない問題
Turbo ではページが再ロードされないので、初期化は turbo:load へ寄せるのが基本です。
また、スナップショット保存前に turbo:before-cache が発火します。
// app/javascript/application.js
document.addEventListener("turbo:load", () => {
// initialize widgets
})
document.addEventListener("turbo:before-cache", () => {
// teardown temporary UI (open dropdowns, etc.)
})🪟 B. Turbo Frames(部分更新)
Turbo Frames は、ページ内の特定領域だけを独立して更新できます。
レスポンスがフル HTML でも、該当する <turbo-frame id="..."> だけ抽出して差し替えます。
<%= turbo_frame_tag "messages_list" do %>
<%= render @messages %>
<%= link_to "Next Page", messages_path(page: 2) %>
<% end %>✅ 1ページに複数 Frames は置ける?
置けます。ID は必ずユニーク。フレームごとに遷移コンテキストを持ちます。
💤 src + loading="lazy" で遅延ロード
src 属性で lazy-load でき、loading="lazy" なら表示領域に入るまで遅延できます。
<%= turbo_frame_tag "slow_panel",
src: reports_path,
loading: :lazy do %>
<div class="skeleton">Loading…</div>
<% end %>📡 C. Turbo Streams(複数箇所更新・リアルタイム)
Turbo Streams は <turbo-stream> を返して、複数箇所を同時に更新できます。Rails は controller/model/job から簡単に使えます。
# app/controllers/notifications_controller.rb
def create
@notification = Notification.create!(notification_params)
render turbo_stream: turbo_stream.append(
"notifications",
partial: "notifications/notification",
locals: { notification: @notification }
)
end
🔌 Action Cable で「HTML をブロードキャスト」する
turbo-rails は Active Job と Action Cable を使って、モデル変更を WebSocket で配信できます。
Step 1: View (receiver)
<%= turbo_stream_from "messages_channel" %>
<h1>Live Chat</h1>
<div id="messages_list">
<%= render @messages %>
</div>
<%= render "form", message: Message.new %>Step 2: Partial (content)
<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id(message) %>" class="message-card">
<strong>User says:</strong>
<%= message.content %>
</div>Step 3: Model (broadcaster)
# app/models/message.rb
class Message < ApplicationRecord
after_create_commit -> {
broadcast_append_to "messages_channel",
target: "messages_list",
partial: "messages/message",
locals: { message: self }
}
endフロー:
- ユーザーAが送信
- DBコミットが実行
- Railsが
_message.html.erbをHTMLとしてレンダリング - Action Cableがレンダリング済みHTMLをブロードキャスト
- サブスクライブしている全クライアントが即時に追加表示
JSONシリアライズなし。クライアント側テンプレートなし。
🤖 AI × Turbo(現実的な実装例)
「AI の結果が返ってきたら画面が勝手に更新される」を Turbo Streams で作る定番パターンです。
- ユーザーが prompt を投稿
- Job が LLM を呼ぶ
- DB 更新で
broadcast_replace_toが走り UI が更新
Model
# app/models/ai_request.rb
class AiRequest < ApplicationRecord
after_create_commit -> {
broadcast_prepend_to "ai_requests",
target: "ai_requests",
partial: "ai_requests/request",
locals: { request: self }
}
after_update_commit -> {
broadcast_replace_to "ai_requests",
target: dom_id(self),
partial: "ai_requests/request",
locals: { request: self }
}
endController
# app/controllers/ai_requests_controller.rb
def create
@request = AiRequest.create!(prompt: params[:prompt], status: "queued")
AiRequestJob.perform_later(@request.id)
respond_to do |format|
format.html { redirect_to ai_requests_path, status: :see_other }
format.turbo_stream
end
endJob(プロバイダー非依存の「AIクライアント」)
# app/jobs/ai_request_job.rb
class AiRequestJob < ApplicationJob
queue_as :default
def perform(request_id)
req = AiRequest.find(request_id)
req.update!(status: "running")
answer = AiProvider.generate(prompt: req.prompt) # implement however you like
req.update!(status: "done", answer: answer)
rescue => e
req.update!(status: "failed", answer: "Error: #{e.class}") if req
end
end
# app/services/ai_provider.rb
class AiProvider
def self.generate(prompt:)
# Call your LLM here (OpenAI, Anthropic, local model, etc.)
# Keep timeouts + retries in mind in production.
"AI says: #{prompt.reverse}" # placeholder
end
endIndex view (受信側)
<%= turbo_stream_from "ai_requests" %>
<div id="ai_requests">
<%= render @requests %>
</div>Partial (各リクエストが独立して差し替え可能な単位)
<!-- app/views/ai_requests/_request.html.erb -->
<turbo-frame id="<%= dom_id(request) %>">
<div class="card">
<div><strong>Prompt:</strong> <%= request.prompt %></div>
<div><strong>Status:</strong> <%= request.status %></div>
<% if request.answer.present? %>
<pre><%= request.answer %></pre>
<% else %>
<div class="spinner">Thinking…</div>
<% end %>
</div>
</turbo-frame>結果:
リアルタイムのように感じられる「AI機能」。
しかしUIはあくまで、通常のRailsビュー+パーシャルのまま。
🧬 Turbo 8: Morphing で “更新が滑る”
Turbo はページリフレッシュ時に <body> 全差し替えではなく、DOM を morph(差分適用) できます。スクロールやフォーカスが保たれます。
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">内部的には idiomorph を利用します。
🧊 キャッシュ(スナップショット)周りの実務ポイント
Turbo Drive はスナップショットをキャッシュし、戻る操作やプレビューを高速化します。
実務で効く道具:
turbo:before-cacheで UI を片付けるdata-turbo-temporaryで一時要素をキャッシュさせない<meta name="turbo-cache-control" content="no-preview">/no-cachedata-turbo-permanentで DOM を維持
📱 Turbo Native(Web をベースにモバイルへ)
Turbo Native は Turbo 対応 Web アプリを iOS/Android のネイティブシェルでラップし、単一 WebView とネイティブナビゲーション UI を提供します。
1. Rails側でネイティブアプリからのアクセスを判定する
Webブラウザではヘッダーやフッターを表示したいが、ネイティブアプリではネイティブのUI(タブバーなど)を使うため、それらを隠したい場合によく使います。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :turbo_native_app?
def turbo_native_app?
# iOS/AndroidのTurboライブラリはUser-Agentに "Turbo Native" を含めます
request.user_agent.to_s.match?(/Turbo Native/)
end
end
使用例(View):
<% unless turbo_native_app? %>
<%= render "layouts/header" %>
<% end %>2. Path Configuration (JSON)
iOS/Androidのコードを一切書かずに、特定のリンク(例: 「新規作成」「編集」)をネイティブのモーダルウィンドウとして開かせる設定です。これにより「Webっぽさ」が消え、ネイティブアプリの操作感になります。
Web側の public/configurations/ios.json (または android.json) に配置します。
{
"rules": [
{
"patterns": [
"/posts/new$",
"/posts/.*/edit$"
],
"properties": {
"presentation": "modal"
}
}
]
}解説: URLが
/newや/editで終わるリンクをクリックすると、画面遷移ではなく、下からスライドアップする「モーダル」として開きます。
3. JavaScript Bridge (Stimulus)
Web上のボタンを押した時に、ネイティブ機能(触覚フィードバック、プッシュ通知の許可ダイアログ、ログアウト処理など)を呼び出すためのシンプルなブリッジです。
// app/javascript/controllers/bridge_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// ボタンクリックで呼び出す: <button data-action="bridge#haptic">
haptic() {
if (window.webkit && window.webkit.messageHandlers) {
// iOS側の "hapticFeedback" ハンドラを叩く
window.webkit.messageHandlers.hapticFeedback.postMessage({});
}
}
}
🧭 使い分け早見表
| 機能 | 使いどころ | メモ |
|---|---|---|
| Turbo Drive | 遷移の基本(大半) | フルリロード回避 |
| Turbo Frames | 画面内ウィジェット | ID ユニーク必須 |
| Turbo Streams | 複数箇所更新 / 通知 / チャット | WebSocket もフォームも可 |
| Morphing | リフレッシュを滑らかに | scroll/focus 保持 |
| Turbo Native | ハイブリッドモバイル | ネイティブ殻 + Web 共有 |
