Turboは、JavaScriptをほとんど書かずにページ遷移とフォーム送信をSPAのように速くする、Hotwireの中核フレームワークです。サーバーはJSONでなくHTMLを返し、Turboがそれを受け取って画面の必要な部分だけを差し替えます。Ruby on Railsでは7.0以降の新規アプリに標準で入り、2026年10月26日時点の最新版は @hotwired/turbo 8.0.23(2026年1月29日公開)、Rails連携用のgemはturbo-rails 2.0.23です。
この記事では、導入手順、Drive・Frames・Streamsの仕組みと無効化の書き方、一覧をその場で編集・追加する実装例、テスト、Rails UJSからの移行で壊れる箇所までを、Rails 8.1系とturbo-rails 2.0系を前提に扱います。
なお「turbo」の読み方は「ターボ」で、英語ではターボチャージャー(過給機)の略として使われます。検索では同名のファンクラブや自動車部品も多く出ます。この記事で扱うのはWeb開発のTurbo(hotwired/turbo)です。名前の近いVercelのモノレポ用ビルドツールはTurborepoとは:2.11系のturbo.json設定・キャッシュ共有・CI高速化を実装で解説、Next.jsのバンドラはTurbopackとは:Next.js 16で既定になったRust製バンドラの設定と移行判断で扱っており、どちらもTurboとは無関係です。
まとめ:Ruby on RailsでTurboを使うときの要点
- Turboは Turbo Drive(遷移の高速化)・Turbo Frames(部分差し替え)・Turbo Streams(複数箇所の更新とリアルタイム配信)の3機能からなる。スマホアプリ向けだったTurbo Nativeは、現在Hotwire Nativeとして別リポジトリで開発されている
- 最新は @hotwired/turbo 8.0.23 と turbo-rails 2.0.23(どちらも2026年1月29日公開、2026年10月26日時点)。turbo-rails 2.0.23の動作にはRuby 3.1以上が必要
- Railsでは
rails newで最初から入る。既存アプリはbundle add turbo-railsとbin/rails turbo:installの2行で追加できる - Turbo Driveで状態を変更するフォーム送信に通常のHTMLを返すなら、成功時は303などでリダイレクト、検証エラー時は422などの4xxを返す。リダイレクトなしの200はエラー扱いになる(Frame内の送信とTurbo Streams応答は別の規則)
- 一覧の1行をその場で編集するなら
turbo_frame_tag、行の追加とフォームのリセットはcreate.turbo_stream.erbで返す - Turbo 8で、現在のページを取り直す page refresh(設定すればモーフィングで差分だけ反映)と、マウスを乗せたリンクの先読みが入った
- Turbo Driveを止めるなら、要素単位は
data-turbo="false"、アプリ全体はTurbo.config.drive.enabled = false - 8.0.21以降は、SVGの中に置いたリンクがエラーを出してページ全体を読み直す。修正は未リリースのため、SVGをHTMLの
<a>で包んで避ける - Rails UJSからの移行では
data-confirmとmethod: :deleteのリンクが黙って動かなくなる。data-turbo-confirmとdata-turbo-methodへ書き換える - 画面上で複雑な状態を持つ編集ツールやオフライン動作が要るアプリには向かない。サーバーがHTMLを描く前提が崩れるため
Turboとは:Hotwire・Turbolinksとの関係と2026年10月時点の版
HotwireはBasecamp(現37signals)が公開した「HTMLをサーバーから送る」構成の総称で、Turbo・Stimulus・Hotwire Nativeで構成されます。TurboはそのうちHTMLの取得と差し替えを受け持ち、Stimulusは差し替え後のHTMLに小さな振る舞いを付ける役です。Hotwire全体の構成とStimulusについてはHotwireとは?Turbo・Stimulus・Hotwire Nativeの役割とRails 8.1での使い方にまとめています。
Turbo DriveはTurbolinksの後継です。リンクを横取りする基本は同じで、フォーム送信も横取りするようになり、FramesとStreamsが加わりました。
| 版 | 公開日 | 主な変化 |
|---|---|---|
| Turbolinks 5.2.0 | 2018-08-20 | Turbolinksの最終版 |
| Turbo 7.0.0-beta.1 | 2020-12-22 | Turboとして初公開 |
| Turbo 7.0.0 | 2021-09-24 | 正式版。Rails 7の既定に採用 |
| Turbo 8.0.0 | 2024-02-07 | page refresh、先読み、TS廃止 |
| Turbo 8.0.6 | 2024-09-13 | 設定窓口 Turbo.config を導入 |
| Turbo 8.0.21 | 2026-01-16 | SVG内リンクの不具合が混入 |
| Turbo 8.0.23 | 2026-01-29 | 2026年10月26日時点の最新 |
日付はnpmレジストリの公開日時で、各版の変更点はhotwired/turboのリリースノートで確認できます。8.0.23の公開から9か月近く、npmの latest は動いていません。GitHubのmainには2026年6月以降もSVGリンクの修正などがマージされていますが、版としては出ていない状態です。Turbo 8ではソースがTypeScriptからJavaScriptへ戻され(2023年9月6日マージのPull Request #971)、8.0.23のnpmパッケージにも型定義は同梱されていません。TypeScriptから使う場合は型を自前で用意するか、型チェックの対象外にします。
Rails側のgemはrubygems.orgのturbo-railsで版を追えます。2.0.23は本体と同じ日に公開され、必要なRubyは3.1以上です。Rails本体は2026年9月24日公開の8.1.4が最新で、以下のコード例はこの組み合わせを前提にしています。
Railsに導入する:rails newとturbo:installで既存アプリへ追加
Rails 7以降で作ったアプリには、turbo-railsとstimulus-railsが最初から入っています。Rails 8.1.4のジェネレータは、--skip-hotwire のほか、Hotwireの省略を伴う --skip-javascript や --api を付けない限り、両方のgemをGemfileに書いて turbo:install stimulus:install を実行します。次のコマンドでTurbo入りのアプリを作り、実装例で使うTaskモデルとテーブルを用意します。
rails new todo_app
cd todo_app
bin/rails generate model Task title:string
bin/rails db:migrate
モデルやscaffoldを作るジェネレータの使い分けはRailsジェネレータ(rails generate)の使い方|モデル・scaffold・カスタム生成までで扱っています。scaffoldが出力するコントローラーは、後述するTurboの応答規則(303と422)に沿ったコードになっています。
既存アプリに追加する場合は、Ruby 3.1以上・Rails 7.1以上で、importmapまたはJavaScriptのビルド環境を設定済みであることが前提です。Rails 7.0以前のアプリは先にRailsを更新します。7.2系からの更新を検討しているならRails 7.2の主な変更点とサポート終了日|Rails 8.1への移行判断も参考になります。
bundle add turbo-rails
bin/rails turbo:install
turbo:install は config/importmap.rb があればimportmap用の処理を、なければ bun.config.js と package.json に応じてBunかYarnでパッケージを追加します。どちらの構成もなければ何も導入しません。importmapで入れる場合は、importmap-railsがGemfileでturbo-railsより上に書かれている必要があります。
JavaScriptの読み込みは app/javascript/application.js に置く次の1行で、これで window.Turbo も使えるようになります。導入後にブラウザの開発者ツールで Turbo.session.drive が true を返せば、Turbo Driveが有効です。
import "@hotwired/turbo-rails"
Turbo Driveでリンク遷移とフォーム送信を速くする仕組みと設定
Turbo Driveは、同一オリジンへのリンクのクリックとフォーム送信を横取りし、fetchで次のページを取得します。取得したHTMLの <body> を差し替え、<head> には新しく現れたスタイルシートとスクリプトだけを足すので、読み込み済みのJavaScriptとスタイルを使い回せます。Rails以外で使うなら、有効にするのは import "@hotwired/turbo" の1行だけです。
取得に500ミリ秒以上かかると、画面上部にプログレスバーが出ます。この閾値は Turbo.config.drive.progressBarDelay で変えられます。PDFやZIPなど拡張子で判定されるファイルへのリンクは、最初から横取りの対象外です。
フォーム送信の応答は303リダイレクトか422で返すのがTurboの規則
Turbo Drive下のフォームで「送信しても画面が変わらない」ときは、応答のステータスコードを確認します。8.0.23は、Turbo Frameの外で行うGET以外の送信への応答を次のように扱います(Frame内の送信とTurbo Streamsの応答は別の規則)。規則の全体はTurbo Handbookの「Drive」章にあります。
| 応答 | Turboの動作 |
|---|---|
| リダイレクト(303など) | リダイレクト先へ遷移 |
| 200でリダイレクトなし | エラーを出して何もしない |
| 4xx(422など) | 返ってきたHTMLで画面を描き直す |
| 5xx | エラー画面として描画 |
200で何もしないとき、ブラウザのコンソールには Form responses must redirect to another location と出ます。検証エラーでフォームを再表示するなら422を返す必要があり、200のままでは入力エラーが画面に出ません。POST後の200を認めないのは、ブラウザが再読み込み時に出す「フォームを再送信しますか」の確認をTurboでは再現できず、表示中のURLとフォームの送信先もずれるためです。Rails 8.1.4のscaffoldはこの規則どおりのコードを生成します。
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, notice: "Post was successfully created."
else
render :new, status: :unprocessable_content
end
end
def destroy
@post.destroy!
redirect_to posts_path, notice: "Post was successfully destroyed.", status: :see_other
end
:unprocessable_content は422の新しい名前です(Rack 3.1以降)。古いRailsでは :unprocessable_entity と書きます。DELETEの後に status: :see_other(303)を付けるのは、既定の302だとブラウザがリダイレクト先にもDELETEを送る場合があるためです。
Turbo 8のリンク先読みを止める3つの方法とサーバー側の注意点
Turbo 8から、マウスカーソルがリンクに乗ると100ミリ秒後に遷移先を先読みします。先読みした結果は10秒間保持され(<meta name="turbo-prefetch-cache-time"> にミリ秒で指定すれば変更可能)、その間にクリックすれば通信を待たずに表示できます。サーバー側には X-Sec-Purpose: prefetch ヘッダー付きのリクエストが届くので、アクセス数の集計や副作用のあるGETには注意が要ります。
先読みだけを止める方法は、範囲に応じて3通りあります。
- サイト全体:
<meta name="turbo-prefetch" content="false">を<head>に置く - 特定のリンクや領域:
data-turbo-prefetch="false"を付ける - JavaScriptから条件付きで止める:
turbo:before-prefetchイベントでevent.preventDefault()を呼ぶ
Turbo Driveを無効にする範囲別の書き方
外部の決済フォームや地図のように、初回の読み込みで初期化される前提のスクリプトがある画面は、そこだけTurbo Driveを外すのが手早い対処です。範囲ごとの書き方は次のとおりです。
| 止める範囲 | 書き方 |
|---|---|
| リンク・フォーム1つ | data-turbo="false" を付ける |
| 領域の中すべて | 親要素に data-turbo="false" |
| アプリ全体 | Turbo.config.drive.enabled = false |
| そのページへの遷移 | turbo-visit-control のmetaで reload |
8.0.23は最も近い祖先の data-turbo を見るので、止めた領域の中でも data-turbo="true" を付けた要素はTurbo Driveに戻ります。アプリ全体で止めたときも、使いたいリンクにだけ "true" を付けます。Turbo Handbookの「Drive」章に載る Turbo.session.drive = false は、同じ設定を書き換える旧来の書き方です。ページ側に <meta name="turbo-visit-control" content="reload"> を置くと、そのページへの遷移はページ全体の読み込みに切り替わります。フォームまで止めると、422による再描画やTurbo Streamsの応答も使えなくなる点には注意が要ります。
DOMContentLoadedが遷移後に発火しない問題とturbo:load
Turbo Driveはページを再読み込みしないので、DOMContentLoaded は最初の1回しか発火しません。遷移のたびに実行したい処理は turbo:load に書きます。Turbolinks時代の turbolinks:load も、そのままでは発火しません。
document.addEventListener("turbo:load", () => {
// 遷移のたびに実行される
initDatePickers()
})
要素ごとの初期化が多いなら、Stimulusのコントローラーに移すほうが確実です。Stimulusは要素がDOMに現れた時点で connect() を呼ぶため、Turboの遷移やFramesの差し替えを意識せずに済みます。
SVG内のリンクが8.0.21以降Turboの遷移にならない不具合
8.0.21(2026年1月16日公開)から、<svg> の中に置いた <a href> をクリックすると、コンソールに link.href.startsWith is not a function と出てページ全体の読み込みに落ちます。SVGの <a> の href は文字列でなく SVGAnimatedString を返すためで、原因はPull Request #1285(2025年11月18日マージ)が足した判定だとIssue #1510に報告されています。npmパッケージを比べると、リンク判定の関数 findLinkFromClickTarget にこの判定が入ったのは8.0.21です。
Playwright同梱のChromium 156で同じHTMLを8.0.20と8.0.23で開き、リンクをクリックした結果が次の表です(「Turbo遷移」は読み直さずに本文が差し替わったこと)。
| クリックした要素 | 8.0.20 | 8.0.23 |
|---|---|---|
HTMLの <a> |
Turbo遷移 | Turbo遷移 |
SVG内の <a> |
Turbo遷移 | 全体読込+TypeError |
SVGをHTMLの <a> で包む |
Turbo遷移 | Turbo遷移 |
リンク先には移動できるので、画面の状態が消えるまで気付きにくい不具合です。修正は2026年6月27日にmainへマージされ、先読みの処理にある同じ判定も直されましたが、2026年10月26日時点で版は出ておらず、turbo-rails 2.0.23が同梱するのも8.0.23です。アイコンやグラフをリンクにするときは、SVGの外側をHTMLの <a> で包みます。
Turbo Frames:同じidのフレームによる一覧行の差し替え
Turbo Framesは、<turbo-frame> で囲んだ範囲を独立した小窓として扱う機能です。フレーム内のリンクやフォームの結果は、応答HTMLから同じidのフレームだけを取り出して差し替えます。
<turbo-frame id="post_1">
<h3>記事タイトル</h3>
<a href="/posts/1/edit">編集</a>
</turbo-frame>
<!-- 表示領域に入ってから読み込む -->
<turbo-frame id="comments" src="/posts/1/comments" loading="lazy">
読み込み中…
</turbo-frame>
応答に同じidのフレームが無いと、フレーム内に「Content missing」と表示され turbo:frame-missing イベントが発火します。フレームの外へ遷移させたいリンクに付ける属性は data-turbo-frame="_top" です。遅延読み込みや target 属性などの詳細はTurbo Framesとは?turbo_frame_tagの使い方とContent missingの対処【Turbo 8.0.23】で扱っています。
Railsでは、フレームからのリクエストに Turbo-Frame ヘッダーが付くと、turbo-railsがレイアウトを turbo_rails/frame に差し替えます。<head> にCSRFのmetaタグと content_for :head の内容を出力する最小限のレイアウトで、判定用のメソッドは turbo_frame_request? です。仕組みの説明はTurbo Handbookの「Frames」章にあります。
Turbo Streams:8アクションによる複数箇所の更新
Turbo Streamsは、1つの応答で画面の複数箇所を更新する仕組みです。サーバーは text/vnd.turbo-stream.html という種類のHTMLを返し、その中の <turbo-stream> 要素ごとに「どの要素に何をするか」を指定します。8.0.23で使えるアクションは次の8つで、属性の一覧はTurbo Referenceの「Streams」に載っています。
| action | 対象要素への操作 |
|---|---|
append |
子の末尾に追加 |
prepend |
子の先頭に追加 |
replace |
要素ごと置き換え |
update |
中身だけ置き換え |
remove |
要素を削除 |
before |
要素の直前に挿入 |
after |
要素の直後に挿入 |
refresh |
ページ全体を再取得 |
replace と update に method="morph" を付けると、丸ごと置き換える代わりに差分だけを反映します。未保存の入力値まで残る保証はないため、入力欄は一意のidと data-turbo-permanent で更新対象から外します。
次のHTMLを Turbo.renderStreamMessage() に渡すと、#messages への追加、#message_1 の置き換え、#flash の削除が1回で行われます(Playwright同梱のChromium 156と8.0.23で実行して確認)。
<turbo-stream action="append" target="messages">
<template><li id="message_2">新着</li></template>
</turbo-stream>
<turbo-stream action="replace" target="message_1">
<template><li id="message_1">更新済み</li></template>
</turbo-stream>
<turbo-stream action="remove" target="flash"></turbo-stream>
Railsのフォーム応答で返す方法とAction Cableで配信する方法
届け方は2通りです。1つはフォーム送信への応答で、Turboは GET以外の送信に text/vnd.turbo-stream.html を受け付けるヘッダーを付けます。Railsでは respond_to で振り分けます。
def create
@message = @room.messages.create!(message_params)
respond_to do |format|
format.turbo_stream # app/views/messages/create.turbo_stream.erb を描画
format.html { redirect_to @room }
end
end
もう1つはWebSocketやServer-Sent Eventsで、操作した本人以外の画面にも届ける方法です。turbo-railsではビューに turbo_stream_from を置き、モデルから broadcast_append_to などを呼ぶと、Action Cable経由で購読中のブラウザに配信されます。
<%# app/views/rooms/show.html.erb %>
<%= turbo_stream_from @room %>
<div id="messages"><%= render @room.messages %></div>
モデル側(app/models/message.rb):
class Message < ApplicationRecord
belongs_to :room
after_create_commit -> { broadcast_append_to room, target: "messages" }
end
開発環境のAction Cableは単一プロセスの async アダプタで動くため、bin/rails console から配信しても別プロセスのサーバーにつながったブラウザには届きません。turbo-railsのREADMEが明記している落とし穴です。Action Cable自体の仕組みはAction Cableとは?Rails 8.1のWebSocket実装とSolid Cable・Redisの選び方、配信方式の選び方はSSEとWebSocketの違いと使い分け|WebRTC・WebTransportまで性能比較【2026年10月】を参照してください。
Railsの実装例:一覧行の編集・追加画面
ここまでの部品で、行単位の編集と新規追加ができるタスク一覧を、JavaScriptを書かずに作ります。前提は前章の rails new と Task モデルの作成です。
タスク画面のルーティングとformat.turbo_streamによる応答分岐
モデルには空のタイトルを弾く検証を1行足し(validates :title, presence: true)、ルーティングは一覧・追加・編集・更新の4つに絞ります。
# config/routes.rb
Rails.application.routes.draw do
resources :tasks, only: %i[index create edit update]
root "tasks#index"
end
コントローラーの要点は create の分岐です。保存できたら create.turbo_stream.erb を描き、失敗したらフォームだけを描き直すStreamを422付きで返します。Turbo 8.0.23はStreams応答かをContent-Typeだけで判定するので、422のStreamもそのまま反映されます。format.html を残せば、JavaScriptが無効な環境でも通常の遷移で動きます。
class TasksController < ApplicationController
before_action :set_task, only: %i[edit update]
def index
@tasks = Task.order(:id)
@task = Task.new
end
def create
@task = Task.new(task_params)
respond_to do |format|
if @task.save
format.turbo_stream
format.html { redirect_to tasks_path, status: :see_other }
else
format.turbo_stream do
render turbo_stream: turbo_stream.update("new_task", partial: "tasks/form", locals: { task: @task }),
status: :unprocessable_content
end
format.html do
@tasks = Task.order(:id)
render :index, status: :unprocessable_content
end
end
end
end
def edit
end
def update
if @task.update(task_params)
redirect_to tasks_path, status: :see_other
else
render :edit, status: :unprocessable_content
end
end
private
def set_task
@task = Task.find(params[:id])
end
def task_params
params.expect(task: [:title])
end
end
params.expect はRails 8.0で入った書き方で、7.x系では params.require(:task).permit(:title) と書きます。update は一覧へ303でリダイレクトしますが、フレーム内の送信なので、Turboはリダイレクト先から同じidのフレーム(編集した行)だけを取り出して差し替えます。
turbo_frame_tag:一覧行と編集フォームのid統一
行の部分テンプレートと編集画面を、同じ turbo_frame_tag task で囲むのが要です。レコードを渡すと dom_id が使われ、idは task_1 のようになります。
<%# app/views/tasks/index.html.erb %>
<h1>タスク</h1>
<div id="tasks">
<%= render @tasks %>
</div>
<div id="new_task">
<%= render "form", task: @task %>
</div>
<%# app/views/tasks/_task.html.erb %>
<%= turbo_frame_tag task do %>
<%= task.title %>
<%= link_to "編集", edit_task_path(task) %>
<% end %>
<%# app/views/tasks/edit.html.erb %>
<%= turbo_frame_tag @task do %>
<%= render "form", task: @task %>
<%= link_to "取消", tasks_path %>
<% end %>
<%# app/views/tasks/_form.html.erb %>
<%= form_with model: task do |f| %>
<% task.errors.full_messages.each do |message| %>
<p><%= message %></p>
<% end %>
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
「取消」もフレーム内のリンクなので、一覧から同じidの行だけを戻します。ほかの行は描き直されず、スクロール位置もそのままです。
create.turbo_stream.erb:行追加と入力欄リセットの応答
追加に成功したときに返すStreamは2つです。1つ目で一覧の末尾に行を足し、2つ目で入力欄を空の新規フォームに戻します。turbo_stream.append にレコードを渡すと、tasks/_task の部分テンプレートが自動で選ばれます。
<%# app/views/tasks/create.turbo_stream.erb %>
<%= turbo_stream.append "tasks", @task %>
<%= turbo_stream.update "new_task", partial: "tasks/form", locals: { task: Task.new } %>
開発者ツールのネットワーク欄では、追加の応答が text/vnd.turbo-stream.html、編集リンクのリクエストに Turbo-Frame: task_1 ヘッダーが付くことで動作を確かめられます。他の利用者の画面にも追加を反映するなら、Taskモデルに after_create_commit -> { broadcast_append_to :tasks, target: "tasks" } を、一覧ビューに <%= turbo_stream_from :tasks %> を追加します。
Turbo 8:ページリフレッシュとモーフィングによる差分反映
Turbo 8.0.0で入った page refresh は、現在のページを再取得して更新する機能です。既定ではbodyを置き換え、turbo-refresh-method に morph を指定すると差分反映になります。差分反映にはidiomorphを使い、設定の詳細はTurbo Handbookの「Page Refreshes」章にあります。
<%# レイアウトの head 内 %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%# 出力されるmetaタグ %>
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
モデル側に broadcasts_refreshes を書くと、変更のたびに refresh アクションが配信されます。turbo-rails 2.0.23のソースでは、作成時はモデル名の複数形(例:"messages")のストリームへ、更新・削除時はそのレコード自身のストリームへ配信します。新規作成を一覧画面に反映させたいなら、一覧側で turbo_stream_from :messages のように複数形の名前でも購読しておくことが必要です。
page refreshは更新箇所を考えずに済む代わりに、サーバーが毎回ページ全体を描画します。更新頻度が高い画面や重い一覧では、Streamsで対象を絞るほうが軽く済みます。
Turboを使う画面をRailsのテストで確かめる3つのヘルパー
turbo-rails 2.0.23は、読み込まれた時点で assert_turbo_stream(返したStreamのactionとtarget)、assert_turbo_frame(指定idのフレームの有無)、assert_turbo_stream_broadcasts(モデルからの配信の有無と回数)をRailsのテストクラスへ自動で組み込みます(lib/turbo/engine.rb の初期化処理)。次は前章の create を結合テストで確かめる例です。as: :turbo_stream を付けると、Turboと同じAcceptヘッダーでリクエストが送られます。ブロックを渡せば、Streamの中身も assert_select で検査できます。
# test/controllers/tasks_controller_test.rb
require "test_helper"
class TasksControllerTest < ActionDispatch::IntegrationTest
test "追加するとtasksの末尾に行を足すStreamを返す" do
post tasks_path, params: { task: { title: "牛乳を買う" } }, as: :turbo_stream
assert_response :success
assert_turbo_stream action: "append", target: "tasks" do
assert_select "template turbo-frame", text: /牛乳を買う/
end
end
test "空のタイトルは422とフォームの描き直しを返す" do
post tasks_path, params: { task: { title: "" } }, as: :turbo_stream
assert_response :unprocessable_content
assert_turbo_stream status: :unprocessable_content, action: "update", target: "new_task"
end
end
RSpecでもrequest specとは?Rails 8・RSpec 8でのリクエストスペックの書き方の形で同じヘルパーを呼べます。結合テストで分かるのは「どのStreamを返したか」までで、画面で行が差し替わることはシステムテストで確かめます。Capybaraから実ブラウザを動かす構成はRSpecでCapybaraとPlaywrightを使う設定手順|capybara-playwright-driverとRailsの注意点で扱っています。
Rails UJSからTurboへ移行すると黙って壊れる3か所と書き換え
Rails 7より前のアプリをTurboに移すと、エラーも出ずに動作だけが変わる箇所があります。Rails UJSが担っていた属性をTurboは解釈しないためです。data: { confirm: "..." } は data: { turbo_confirm: "..." } に、link_to ..., method: :delete は data: { turbo_method: :delete } か button_to に、remote: true は外してHTMLかStreamsの応答に書き換えます。最も気付きにくいのは確認ダイアログです。Rails UJSを外した状態で data-confirm を残すと、Turboは確認ダイアログを出しません。削除フォームや data-turbo-method="delete" のリンクでは、確認なしで削除要求が送られます。data-turbo-confirm はフォーム送信時にブラウザ標準の confirm() を呼び、独自のダイアログに替えたい場合は Turbo.config.forms.confirm に関数を渡します。
<%= link_to "削除", post_path(@post),
data: { turbo_method: :delete, turbo_confirm: "削除しますか?" } %>
<%# フォームを生成するのでJavaScriptが無効でも動く %>
<%= button_to "削除", post_path(@post), method: :delete,
form: { data: { turbo_confirm: "削除しますか?" } } %>
data-turbo-method 付きのリンクは、Turboが内部でフォームを組み立てて送信します。JavaScriptが動かない環境ではただのGETリンクになるので、削除のように取り消せない操作は button_to にしておくのが安全です。Rails UJSと併用する場合の段階的な手順は、turbo-railsのUPGRADING.mdにあります。
remote: true で .js.erb を返していた画面は、機械的な置き換えでは済みません。返していたJavaScriptが「どの要素をどう変えるか」を読み取り、同じ変更をTurbo Streamsのアクションで表し直します。$("#list").append(...) なら append、$("#item").remove() なら remove という対応で、前章の実装例がそのまま書き換え先の型になります。
Rails以外のサーバーでTurboを使うときの前提とhtmxとの違い
Turboの本体 @hotwired/turbo はRailsに依存しないnpmパッケージで、MITライセンスです。サーバー側の条件は、HTMLを返すことと、フォームの応答で前述のステータスコード規則を守ることだけです。Streamsを返すときはContent-Typeを text/vnd.turbo-stream.html にします。
turbo-railsが提供する turbo_frame_tag や broadcasts_refreshes のようなヘルパーは、ほかのフレームワークでは各コミュニティのパッケージ(SymfonyのSymfony UX Turboなど)に頼るか、自前で書きます。同じ「HTMLを返す」発想でHTML属性だけで部分更新するhtmxとの違いはhtmxの使い方:hx-属性の実装手順からFastAPI連携まで解説【2026年版】で比較できます。Turboは既定で同一オリジンなどの条件を満たすリンクとフォームを横取りし、htmxは属性を付けた要素だけを対象にする、という既定の動作の差が選定の分かれ目です。
Turboを採用すべきでない場面と業務システムで採用する判断基準
Turboは「画面の状態はサーバーが持ち、ブラウザは受け取ったHTMLを表示する」前提で成り立っています。この前提が崩れるアプリでは、Stimulusを積み増すほど構成が複雑になり、ReactやVueで組んだほうが素直です。
- 図形エディタや表計算のように、操作のたびにサーバーへ問い合わせられない細かな状態を画面が持つ
- オフラインでの新規データ操作や画面遷移を主機能にする(Turbo単体ではオフライン用データ保存や同期処理を提供しない)
- 同じAPIをスマホアプリや外部サービスにもJSONで提供する必要があり、HTMLとJSONの二重実装になる
逆に、管理画面・予約フォーム・社内の業務システムのように「一覧→詳細→編集→保存」を繰り返すアプリでは、APIとフロントエンドを分けずに済むTurboの利点が最も出ます。実装例のとおり行の編集や追加はコントローラーとビューの数ファイルで完結し、フロントエンド専任者を置かずにRailsの開発者だけで画面を作り切れます。Bootstrapは見た目を整えるCSSフレームワークで、Turboとは担当する層が違うため、両方を同じアプリで併用できます。
判断に迷ったら、画面の操作を「サーバーに保存してから表示を変えるもの」と「保存せずに画面だけで完結するもの」に分けて数えてみてください。前者が大半ならTurboで足り、後者が画面の中心を占めるならSPAの構成を検討します。Railsで管理画面や業務システムを新たに作る、あるいは既存のRails UJSのアプリをTurboへ移したいといった相談は、業務用・Webアプリ開発で要件整理から実装、移行後の保守まで受けています。
よくある質問
「turbo」の意味と読み方は?
読み方は「ターボ」です。英単語としてはターボチャージャー(過給機)の略として使われます。Web開発でTurboと言う場合は、37signalsが開発するHotwireのフレームワーク hotwired/turbo を指すのが一般的です。
TurboとHotwireの違いは?
Hotwireは構成全体の名前で、TurboはそのうちHTMLの取得と差し替えを担う部品です。Hotwireには他にStimulus(JavaScriptの振る舞い)とHotwire Native(iOS・Androidアプリ)が含まれます。Ruby on Railsでは turbo-rails と stimulus-rails の2つのgemとして入ります。
Turbo Nativeは今も使える?
Turbo Nativeの後継はHotwire Nativeです。Turbo NativeとStradaを1つにまとめたもので、iOS版・Android版とも1.0.0が2024年9月25日に公開されました。旧turbo-iosのREADMEは既存アプリにもHotwire Nativeへの早期移行を求めています。2026年10月26日時点の最新版はiOSが1.3.1(2026年8月14日)、Androidが1.3.2(2026年10月12日)です。WebのTurbo DriveとFramesで作った画面を、そのままネイティブアプリの画面として表示できます。
TurboとTurborepo・Turbopackは関係がある?
関係はありません。TurborepoとTurbopackはVercelが開発するJavaScriptのビルド関連ツールで、TurboはHotwireのフロントエンドフレームワークです。Turborepoのコマンド名が turbo であるため、検索結果で混ざりやすくなっています。
Turbo DriveのJavaScriptが遷移後に動かないのはなぜ?
DOMContentLoaded は初回の文書読み込み時にしか発火しないためです(body内のscript要素はTurboの描画時にも評価されます)。turbo:load に書き換えるか、Stimulusのコントローラーへ移します。画面単位で外したいなら data-turbo="false" を使います。