turbo について
Turbo Drive、Frames、Streamsを使い、独自JavaScriptを抑えて画面を構築するRails開発者向けスキルです。部分更新やリアルタイム配信、フォーム、Turbo 8のMorphingを扱います。
主な機能
- 01Turbo Driveのナビゲーションと無効化
- 02Turbo Framesによる部分更新
- 03Turbo StreamsによるDOM更新
- 04ActionCableのリアルタイム配信
- 05Turbo 8のページMorphing
- 06Turboフォームとバリデーション処理
活用シーン
- 01Railsページに遅延読み込みを追加
- 02インライン編集、タブ、モーダルの構築
- 03ActionCableでリアルタイム更新を配信
- 04Turboフォームの検証エラーを処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill turboインストール後、Claude Code で/turboを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/broadcasting.mdedge-cases.mdframes.mdmorphing.mdstreams.mdtesting.md詳細
Rails Turbo エキスパート
Turbo Drive、Frames、Streams を使って、カスタム JavaScript を使わずに(または最小限に抑えて)高速でモダンな Rails UI を構築します。
フィロソフィー
基本原則:
- ワイヤー越しの HTML — サーバーが HTML をレンダリングし、Turbo がそれを届けます。UI に JSON API は使いません。
- プログレッシブエンハンスメント — Turbo Drive は自動で動作します。Frames と Streams はその上に重ねて使います。
- サーバーが信頼できる唯一の情報源 — ビジネスロジックは Ruby に置きます。Turbo は HTML をやり取りするだけです。
- 最小限の JavaScript — DOM を更新するために JS を書いているなら、おそらくやり方が間違っています。Turbo Streams を使いましょう。
- 組み合わせ可能なプリミティブ — Drive、Frames、Streams はそれぞれ1つの課題を解決します。組み合わせて使いましょう。
メンタルモデル:
Turbo Drive → Speeds up ALL navigation (automatic, zero config)
Turbo Frames → Scopes updates to a REGION of the page (explicit, per-element)
Turbo Streams → Delivers TARGETED mutations to ANY element (server-pushed or response)
判断フロー:
Need faster page loads? → Turbo Drive (already on)
Need to update PART of a page on click? → Turbo Frame
Need to update MULTIPLE parts at once? → Turbo Stream
Need real-time updates from other users? → Turbo Stream + ActionCable broadcast
Need smooth page refresh without flicker? → Turbo Morph (Rails 8+)
このスキルを使う場面
- ナビゲーションや更新の範囲を限定するために turbo_frame_tag を追加する
- コントローラーのアクションから turbo_stream レスポンスを返す
- ActionCable を使ってリアルタイムのブロードキャストを設定する
- 遅延読み込みコンテンツを実装する
- インライン編集、タブ付きインターフェース、モーダルを構築する
- Turbo Drive の動作を設定する(特定のリンクやフォームで無効にする)
- Turbo 8 のモーフィングとページ更新を使う
手順
ステップ 1:既存の設定を確認する
Turbo Drive は Rails 7 以降のすべてのアプリでデフォルトで有効です。インストールも設定も不要です。すべてのリンクのクリックとフォームの送信は、すでに Turbo Drive を通ります。
アプリに turbo-rails があるか確認します:
# Should already be in Gemfile
grep "turbo-rails" Gemfile
# JavaScript import should exist
rg "import.*turbo" app/javascript/
存在しない場合(Rails 7 以降ではまれです):
bundle add turbo-rails
bin/rails turbo:install
ステップ 2:Turbo Drive — 無効にするタイミングを把握する
Turbo Drive はすべての <a> のクリックと <form> の送信を捕捉し、AJAX で取得して <body> を置き換えます。これは自動で行われます。必要なのは無効にする場合だけです。
<%# Disable for a specific link or form %>
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>
<%= form_with model: @upload, data: { turbo: false } do |f| %>
<%# Disable for a whole section %>
<div data-turbo="false">...</div>
よくある無効化のケース: Active Storage のダイレクトアップロードを使わないファイルアップロード、外部リンク、OAuth/SSO のリダイレクト、ファイルのダウンロード、決済フォーム(Stripe)。
GET 以外のリンクメソッド — turbo_method を付けた link_to より button_to を優先します:
<%# Acceptable %>
<%= link_to "Delete", post_path(@post), data: { turbo_method: :delete, turbo_confirm: "Sure?" } %>
<%# Better — more accessible and semantic %>
<%= button_to "Delete", post_path(@post), method: :delete,
form: { data: { turbo_confirm: "Are you sure?" } } %>
ステップ 3:Turbo Frames — ページの一部を更新する
Turbo Frames は中核となる機能です。ページの領域を指定してナビゲーションを制御します。フレーム内のリンクやフォームを操作すると、そのフレームだけが更新されます。
⚠️ エージェントが最もよく間違える点:対応する turbo_frame_tag で両側を囲むのを忘れること。
現在のページとレスポンスページの両方に、同じ ID の <turbo-frame> が必要です。一致しない場合、Content Missing エラーが発生します。
基本的な Frame パターン:
<%# index.html.erb — the page with the frame %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<h2><%= @post.title %></h2>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# edit.html.erb — the response MUST have matching frame %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<%= render "form", post: @post %>
<% end %>
一貫した ID には dom_id を使います:
<%= turbo_frame_tag dom_id(@post) do %>
<%# dom_id(@post) produces "post_123" %>
<% end %>
遅延読み込みフレーム(ページのレンダリング後にコンテンツを読み込む):
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
src URL は自動で取得されます。loading: :lazy を指定すると、フレームが表示されるまで読み込みを遅らせます(Intersection Observer)。:lazy を指定しない場合は、ページの読み込み後すぐに読み込まれます。
⚠️ エージェントが見落としやすい点:遅延読み込みのエンドポイントは、対応する turbo_frame_tag を含むページを返す必要があります。 コントローラー側でフレームかどうかを把握する必要はありません。通常どおりレンダリングすると、Turbo が一致するフレームを抽出します。
フレームから抜けてページ全体を対象にする:
<%= turbo_frame_tag dom_id(@post) do %>
<%# This link stays in the frame %>
<%= link_to "Edit", edit_post_path(@post) %>
<%# This link breaks out and navigates the full page %>
<%= link_to "Show Full Page", post_path(@post), data: { turbo_frame: "_top" } %>
<% end %>
別のフレームを対象にする:
<%= turbo_frame_tag "sidebar" do %>
<%= link_to "Load Details", post_path(@post), data: { turbo_frame: "main_content" } %>
<% end %>
<%# This frame will be updated instead %>
<%= turbo_frame_tag "main_content" do %>
<p>Select a post from the sidebar</p>
<% end %>
カスタムの target 属性を持つフレーム(フレーム内のすべてのリンクが別のフレームを対象にする):
<%= turbo_frame_tag "nav", target: "main_content" do %>
<%# ALL links here update "main_content" frame instead %>
<%= link_to "Posts", posts_path %>
<%= link_to "Users", users_path %>
<% end %>
ステップ 4:Turbo Streams — DOM の対象を絞った変更
Turbo Streams はピンポイントです。「この HTML を指定した DOM 要素に追加/置換/削除する」と指示します。次の 2 つのコンテキストで動作します。
- HTTP レスポンス — フォーム送信(POST/PUT/PATCH/DELETE)から返されます
- WebSocket ブロードキャスト — ActionCable 経由ですべての接続ユーザーにプッシュされます
7 つのストリームアクション:
| アクション | 動作 |
|---|---|
append | 対象要素の子要素の末尾に HTML を追加します |
prepend | 対象要素の子要素の先頭に HTML を追加します |
replace | 対象要素全体を置き換えます |
update | 対象要素の内部 HTML を置き換えます |
remove | 対象要素を削除します |
before | 対象要素の前に HTML を挿入します |
after | 対象要素の後に HTML を挿入します |
⚠️ 重要な違い:replace と update
replace— 対象要素自体を削除し、新しい HTML で置き換えますupdate— 対象要素はそのまま残し、子要素を置き換えます
<%# replace: <div id="post_1"> is GONE, replaced entirely %>
<%= turbo_stream.replace dom_id(@post), partial: "posts/post", locals: { post: @post } %>
<%# update: <div id="post_1"> stays, its contents are swapped %>
<%= turbo_stream.update dom_id(@post), partial: "posts/post", locals: { post: @post } %>
コントローラーから Turbo Streams で応答する:
# app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.turbo_stream # Renders create.turbo_stream.erb
format.html { redirect_to @post }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "posts", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.update "post_count", html: "#{Post.count} posts" %>
<%= turbo_stream.update "new_post_form" do %>
<%= render "form", post: Post.new %>
<% end %>
⚠️ エージェント向けの注意点:Turbo Stream のレスポンスは GET 以外のリクエストでのみ動作します。 GET リクエストでは Turbo Drive または Frames を使います。GET に対して turbo_stream 形式を返そうとしても、動作しません。代わりにフレームを使ってください。
インラインでストリームをレンダリングする(テンプレートを省略):
def destroy
@post.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@post)) }
format.html { redirect_to posts_path }
end
end
複数のストリームアクションをインラインで指定する:
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove(dom_id(@post)),
turbo_stream.update("post_count", html: "#{Post.count} posts")
]
end
ステップ 5:Turbo Stream のブロードキャスト(リアルタイム)
ブロードキャストは、ActionCable 経由でチャンネルを購読しているすべてのユーザーに Turbo Stream アクションをプッシュします。これにより、JavaScript を書かずに「リアルタイム」機能を実現できます。
ビューで購読を設定する:
<%# This creates an ActionCable subscription %>
<%= turbo_stream_from "posts" %>
<%# Scoped to a specific record %>
<%= turbo_stream_from @project %>
<%# Multiple stream names %>
<%= turbo_stream_from @project, "messages" %>
モデルからブロードキャストする:
class Post < ApplicationRecord
# Broadcast AFTER commit (not after save — important for transactions)
after_create_commit { broadcast_append_to "posts" }
after_update_commit { broadcast_replace_to "posts" }
after_destroy_commit { broadcast_remove_to "posts" }
# Shorthand for all three:
broadcasts_to ->(post) { "posts" }
# Or if broadcasting to a parent:
broadcasts_to :project
end
カスタムの partial を使ってブロードキャストする:
after_create_commit do
broadcast_append_to "posts",
target: "posts_list",
partial: "posts/post_card",
locals: { post: self }
end
任意の場所(コントローラー、ジョブ、サービス)からブロードキャストする:
Turbo::StreamsChannel.broadcast_append_to("posts", target: "posts_list",
partial: "posts/post", locals: { post: @post })
Turbo::StreamsChannel.broadcast_remove_to("posts", target: dom_id(@post))
⚠️ エージェント向けの注意点:ブロードキャストはリクエストコンテキストなしで partial をレンダリングします。 そのため、current_user、request、セッションは利用できません。これらがなくても動作するように partial を設計するか、必要なデータを locals として渡してください。
ステップ 6:Turbo 8 のモーフィング(Rails 8+)
モーフィングは、置き換えではなく差分比較によって DOM を更新するページ更新戦略です。フォームの状態、スクロール位置、CSS トランジションが保持されます。
ページでモーフィングを有効にする:
<%# In the <head> of your layout or page %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
この設定の動作:
method: :morph— 新しい HTML と現在の DOM を比較し、最小限の変更を適用しますscroll: :preserve— 更新後もスクロール位置を保持します
サーバーからモーフィング更新を実行する:
# In a broadcast:
after_update_commit do
broadcast_refresh_to "posts"
end
# Shorthand:
broadcasts_refreshes
モーフィングとストリームの使い分け:
- モーフィング — 状態(フォーム、スクロール)を維持しながらページ全体を再レンダリングしたい場合
- ストリーム — 特定の要素を対象に、ピンポイントで更新したい場合
モーフィングの間、保持する要素を指定する:
<div id="player" data-turbo-permanent>
<%# This element survives morph refreshes intact %>
</div>
ステップ 7:Turbo でのフォーム
フォームは、エージェントが Turbo で最もつまずきやすい箇所です。重要なルールは次のとおりです。
ルール 1:バリデーションに失敗した場合は、必ず 422 Unprocessable Entity を返します。
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
render :new, status: :unprocessable_entity # ← CRITICAL
end
end
:unprocessable_entity がないと、Turbo はエラーレスポンスをレンダリングしません。代わりにリダイレクトのステータスに従います。
ルール 2:フレーム内のフォームはフレーム内にとどまります。
<%= turbo_frame_tag "new_post" do %>
<%= form_with model: Post.new do |f| %>
<%= f.text_field :title %>
<%= f.submit "Create" %>
<% end %>
<% end %>
フォームは Turbo 経由で送信されます。レスポンスには、一致する turbo_frame_tag "new_post" が含まれている必要があります。または、コントローラーが turbo_stream 形式で応答すれば、フレームの要件を省略できます。
ルール 3:フォーム送信が成功したらリダイレクトします。
# Turbo handles redirects correctly — it navigates the page
if @post.save
redirect_to @post, notice: "Created!" # Works fine with Turbo
end
ルール 4:Rails 7 以降では、form_with はデフォルトで Turbo を使います。 local: false は不要です。無効にするには:
<%= form_with model: @post, data: { turbo: false } do |f| %>
ステップ 8:一般的なパターン
詳細な実装については、references/ ディレクトリを参照してください。
references/frames.md— インライン編集、タブナビゲーション、遅延読み込み、モーダル、無限スクロールreferences/streams.md— フラッシュメッセージ、ライブ検索、ネストされたフォーム、カウンター、トースト、テンプレートの規約references/broadcasting.md— スコープ付きブロードキャスト、ユーザー固有のストリーム、バックグラウンドジョブのパターンreferences/morphing.md— Turbo 8 のモーフ更新のセットアップ、モーフとストリームの使い分けreferences/testing.md— インテグレーションテスト、システムテスト、ブロードキャストのアサーションreferences/edge-cases.md— Stimulus との連携、注意点(ファイルアップロード、DELETE リダイレクト、CSP、キャッシュ)
クイックリファレンス
ヘルパーメソッド
<%# Frames %>
<%= turbo_frame_tag "id" %>
<%= turbo_frame_tag dom_id(@record) %>
<%= turbo_frame_tag "id", src: path, loading: :lazy %>
<%= turbo_frame_tag "id", target: "_top" %>
<%# Stream subscription %>
<%= turbo_stream_from "channel_name" %>
<%= turbo_stream_from @record %>
<%# Stream actions (in .turbo_stream.erb templates) %>
<%= turbo_stream.append "target_id", partial: "partial" %>
<%= turbo_stream.prepend "target_id", partial: "partial" %>
<%= turbo_stream.replace dom_id(@record), partial: "partial" %>
<%= turbo_stream.update "target_id", html: "content" %>
<%= turbo_stream.remove dom_id(@record) %>
<%= turbo_stream.before dom_id(@record), partial: "partial" %>
<%= turbo_stream.after dom_id(@record), partial: "partial" %>
<%# Morphing (Rails 8+) %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
データ属性
data-turbo="false" <%# Disable Turbo for this element %>
data-turbo-method="delete" <%# HTTP method for link %>
data-turbo-confirm="Sure?" <%# Confirmation dialog %>
data-turbo-frame="_top" <%# Break out of frame %>
data-turbo-frame="frame_id" <%# Target specific frame %>
data-turbo-permanent <%# Preserve across morphs %>
data-turbo-temporary <%# Remove on morph refresh %>
data-turbo-action="advance" <%# Push to browser history %>
data-turbo-action="replace" <%# Replace browser history entry %>
モデルのブロードキャストメソッド
# Individual callbacks
after_create_commit { broadcast_append_to "stream" }
after_update_commit { broadcast_replace_to "stream" }
after_destroy_commit { broadcast_remove_to "stream" }
# All-in-one
broadcasts_to ->(record) { "stream_name" }
broadcasts_to :parent_association
# Morphing (Rails 8+)
broadcasts_refreshes
after_update_commit { broadcast_refresh_to "stream" }
# Manual broadcast from anywhere
Turbo::StreamsChannel.broadcast_append_to("stream", target: "id", partial: "path")
Turbo::StreamsChannel.broadcast_remove_to("stream", target: "id")
Turbo::StreamsChannel.broadcast_refresh_to("stream")
コントローラーのレスポンスパターン
respond_to do |format|
if @record.save
format.turbo_stream # renders action.turbo_stream.erb
format.html { redirect_to @record }
else
format.html { render :new, status: :unprocessable_entity }
end
end
エージェントがよく起こす間違い
- 一致するフレーム ID がない — 両方のページで同じ ID の
turbo_frame_tagが必要です - バリデーション失敗時に 200 を返す —
status: :unprocessable_entity(422)を指定する必要があります - GET リクエストで Turbo Stream を使う — ストリームが機能するのは POST/PUT/PATCH/DELETE のみです
- ブロードキャストの部分テンプレートで
current_userを使う — ブロードキャストにはリクエストコンテキストがありません - ビューで
turbo_stream_fromを忘れる — ブロードキャストにはサブスクリプションが必要です updateのつもりでreplaceを使う —replaceはターゲット要素全体を削除しますdom_idを使わない — 手動で設定した ID はずれていきますが、dom_id(@post)は一貫していますafter_*_commitを忘れる — ブロードキャストにはafter_saveではなくコミットコールバックを使いますrespond_toに HTML のフォールバックがない — Turbo を使わないクライアント向けに、必ずformat.htmlを含めてください- プレースホルダーコンテンツのない遅延フレーム — フレームタグ内に読み込み状態を表示してください
アンチパターン
- UI 更新のためだけに JSON API を構築する — 代わりに Turbo Streams を使いましょう
- DOM コンテンツを入れ替える JavaScript を書く — そのために Turbo があります
- フレームを深くネストする — 1〜2 階層に抑えてください。複雑さが急激に増大します
- モデルの変更をすべてブロードキャストする — 選択的に行ってください。ブロードキャストが多すぎるとパフォーマンスの問題につながります
- 巨大な turbo_stream.erb テンプレート — ストリームのレスポンスは目的を絞り、1 つのレスポンスにつき 1〜3 個のアクションに抑えてください
- ストリームが必要な用途に Turbo Frames を使う — 関連のない複数の領域を更新する必要がある場合は、Streams を使ってください
- HTML のフォールバックを省略する — アプリは Turbo がなくても動作する必要があります(プログレッシブエンハンスメント)