⚡ Rails Turbo 実践ガイド(HTML over the wire)

⚡ Rails Turbo 実践ガイド(HTML over the wire)

· · 5分で読めます

Advertisement

Turbo(Hotwire) は、SPA のような操作感を目指しつつ、基本は サーバーレンダリングされた HTML を送って画面を更新する仕組みです。
JSON を返してブラウザ側で UI を組み立てるのではなく、完成した HTML を返し、Turbo が差分更新する。これが中核です。


🧩 Turbo と SPA の違い(契約が逆転する)

SPA(React/Vue)

Turbo

Turbo Drive はレスポンスの <body> を使って現在の <body> を更新し、<head> もマージします。


🚗 A. Turbo Drive(ページ遷移の土台)

Turbo Drive は link click と form submit を監視し、裏で fetch して フルリロードなしで更新します。

Rails 側は「普通に書く」でだいたい動く

Rails では Turbo が同梱されており、Turbo Frames/Streams も含めガイドに統合されています。

✅ ここだけは必須: 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

フロー:

  1. ユーザーAが送信
  2. DBコミットが実行
  3. Railsが_message.html.erbをHTMLとしてレンダリング
  4. Action Cableがレンダリング済みHTMLをブロードキャスト
  5. サブスクライブしている全クライアントが即時に追加表示

JSONシリアライズなし。クライアント側テンプレートなし。


🤖 AI × Turbo(現実的な実装例)

「AI の結果が返ってきたら画面が勝手に更新される」を Turbo Streams で作る定番パターンです。

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 }
  }
end

Controller

# 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
end

Job(プロバイダー非依存の「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
end

Index 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 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"
      }
    }
  ]
}

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 共有
Advertisement
#turbo#native#rails#hotwire#turborails#frames#streams#actioncable
Advertisement