Ruby on Rails

Turbo Framesとは?turbo_frame_tagの使い方とContent missingの対処【Turbo 8.0.23】

Turbo Framesとは?turbo_frame_tagの使い方とContent missingの対処【Turbo 8.0.23】

Turbo Framesは、ページの一部を <turbo-frame> 要素で囲み、その範囲のリンクやフォームの結果だけを差し替えるHotwire Turboの機能です。サーバーはいつもどおりHTMLを返し、Turboが応答の中から同じidのフレームを探して中身を入れ替えます。この記事では、2026年1月29日公開の @hotwired/turbo 8.0.23 と turbo-rails 2.0.23 を対象に、turbo_frame_tag の書き方、フォームと遅延読み込みの実装、フレームの外へ遷移させる方法、「Content missing」の原因と対処を、ソースとjsdomでの実測で確かめた範囲で解説します。

まとめ:Turbo Framesの要点

  • フレーム内のリンク・フォームの応答から、同じidの <turbo-frame> だけが取り出されて差し替わる。応答がページ全体のHTMLでも、フレーム外の部分は捨てられる
  • リクエストには Turbo-Frame: フレームのid ヘッダーが付く。turbo-railsはこれを見てレイアウトを最小のものへ差し替える
  • 応答に同じidのフレームが無いと、turbo:frame-missing が発火し、イベントを取り消さなければフレームの中身が「Content missing」に置き換わって例外が投げられる
  • フレーム内フォームの検証エラーは、Rails 8.1のscaffoldと同じく status: :unprocessable_content(422)で返す
  • loading="lazy" を付けたフレームは、表示領域に入るまで src を取りに行かない
  • フレームの外へ出すなら target="_top" か data-turbo-frame="_top"、URLも更新するなら data-turbo-action="advance"
  • 1回の操作で複数箇所を更新したい、サーバーから押し込みたい場合はTurbo Streamsの担当

Turbo Framesの仕組み:同じidによるフレームの部分更新

Turbo Framesの動作は、リクエストとレスポンスの2段で説明できます。フレームの中でリンクがクリックされると、Turboは通常の遷移を止めて fetch で同じURLを取りに行き、リクエストヘッダーに Turbo-Frame を付けます。応答HTMLを受け取ったら、その中から同じidの <turbo-frame> を探し、中身だけを現在のフレームへ移します。

<!-- 一覧ページ -->
<turbo-frame id="post_1">
  <h3>記事タイトル</h3>
  <a href="/posts/1/edit">編集</a>
</turbo-frame>

<!-- /posts/1/edit の応答(ページ全体でよい) -->
<h1>記事の編集</h1>
<turbo-frame id="post_1">
  <form action="/posts/1" method="post">...</form>
</turbo-frame>

jsdom上でTurbo 8.0.23を動かして確かめると、<h1>ページ全体</h1> とフレーム外の段落を含む応答を返しても、画面に入ったのはフレーム内の要素だけでした。送られたリクエストには Turbo-Frame: comments と X-Turbo-Request-Id が付き、GETの Accept は text/html, application/xhtml+xml です。描画が終わるとフレームに complete 属性が付き、turbo:before-frame-render → turbo:frame-render → turbo:frame-load の順でイベントが発火しました。

別オリジンのページを隔離して埋め込む用途は、Turbo Framesではなくiframeとは?sandbox属性14種とCookie分割の実装判断を解説の領域です。

<turbo-frame>の属性と役割

公式リファレンスに載っている属性と、8.0.23のソースで確認できる挙動を並べます。observedAttributes は disabled・loading・src の3つで、この3つは後から書き換えても即座に反映されます。

属性 値 役割
id 文字列(必須) 応答から探すフレームの識別子
src URL このURLの応答でフレームを埋める
loading eager(既定)/lazy lazyは表示領域に入るまで読み込まない
target _top/他フレームのid フレーム内リンクの遷移先
disabled 真偽属性 フレームによる遷移の横取りを止める
busy Turboが付与 リクエスト中だけ付く(aria-busyも同時)
complete Turboが付与 読み込み完了で付く
autoscroll 真偽属性 読み込み後にフレームを画面内へスクロール
recurse 入れ子フレームのid 応答に無いフレームを入れ子経由で探す
refresh morph reload時とページリフレッシュ時にモーフィング
data-turbo-action advance/replace フレーム遷移をURL・履歴にも反映

autoscroll の位置は data-autoscroll-block(既定 end)、動きは data-autoscroll-behavior(既定 auto)で変えられます。busy の間はCSSの turbo-frame[busy] でスピナーを出す、といった使い方ができます。

turbo_frame_tagの書き方:idの組み立てとsrc・target

Railsでは <turbo-frame> を直接書かず、turbo-railsの turbo_frame_tag ヘルパーで出力するのが一般的です。2.0.23のシグネチャは turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block) で、loading などそれ以外の属性はそのままタグに渡ります。

<%# app/views/posts/_post.html.erb %>
<%= turbo_frame_tag post do %>
  <h3><%= post.title %></h3>
  <%= link_to "編集", edit_post_path(post) %>
<% end %>

<%# app/views/posts/edit.html.erb:一覧と同じidで囲む %>
<%= turbo_frame_tag @post do %>
  <%= render "form", post: @post %>
<% end %>

idの決まり方はヘルパーのソースで確定できます。第1引数が to_key に応答するモデルかクラスなら dom_id に渡し、それ以外は引数を _ でつなぎます。

書き方 出力されるid
turbo_frame_tag "tray" tray
turbo_frame_tag Article.find(1) article_1
turbo_frame_tag Article new_article
turbo_frame_tag Article.find(1), "comments" comments_article_1
turbo_frame_tag [user_id, "tray"] 1_tray

モデルと文字列を渡すと文字列が前置される点に注意してください。一覧側で turbo_frame_tag post, "comments"、詳細側で手書きの id="article_1_comments" のように書き方を混ぜると、idが一致せずContent missingになります。両側とも同じヘルパー呼び出しで揃えるのが確実です。

コントローラー側の扱い:レイアウトの差し替えとturbo_frame_request?

turbo-railsは Turbo::Frames::FrameRequest を ActionController::Base に組み込み、Turbo-Frame ヘッダー付きのリクエストではアプリのレイアウトの代わりに turbo_rails/frame.html.erb を使います。中身は csrf_meta_tags と yield :head と本文だけの最小レイアウトで、ヘッダーやフッターの描画を省くための最適化です。アプリ側で独自のレイアウト指定をしている場合は、フレームリクエスト時に "turbo_rails/frame" を返す分岐も必要です。同じURLでもフレーム用とページ用でETagが分かれるよう、etag { :frame if turbo_frame_request? } も設定されます。

ビューやコントローラーで turbo_frame_request? と turbo_frame_request_id が使えるので、フレームから呼ばれたときだけ部分テンプレートを返す、といった分岐が書けます。レイアウトを変えたいときは、アプリ側に app/views/layouts/turbo_rails/frame.html.erb を置けばそちらが優先されます。

フレーム内フォーム:検証エラーは422、成功時の遷移先にも同じフレーム

フレームの中にフォームを置くと、送信結果もそのフレームに描画されます。Rails 8.1.4のscaffoldテンプレートは、検証エラー時に render :new, status: ActionDispatch::Constants::UNPROCESSABLE_CONTENT を生成します。この定数はRack 3.1以上で :unprocessable_content、それ未満で :unprocessable_entity になり、どちらも422です。

def create
  @todo = Todo.new(todo_params)
  if @todo.save
    redirect_to todos_path, status: :see_other
  else
    render :new, status: :unprocessable_content
  end
end

jsdomでフレーム内フォームを送信して確かめると、422で返した検証エラー付きのフォームはそのままフレームに描画されました。一方、200でリダイレクトせずに返した場合もフレーム内には描画されます。Turbo Driveのページ全体へのPOSTなどのフォーム送信では、200でリダイレクトせず通常のHTMLを返す応答は「Form responses must redirect to another location」というエラーになりますが、フレーム内の送信はこの検査の対象外です。それでも422に揃えるべき理由は、同じフォームを後からフレームの外で使ったときにDrive側の検査で止まるためです。scaffoldの書き方に合わせておけば、置き場所を変えても壊れません。

見落としやすいのは成功時です。redirect_to todos_path の遷移先 /todos にも、送信元と同じidのフレームが必要です。一覧ページに new_todo のフレームが無ければ、保存は成功しているのにフレームにはContent missingが出ます。遷移先に同じフレームを置くか、フォームに data-turbo-frame="_top" を付けてページ全体を遷移させるか、どちらかを選びます。

フレーム内フォームをPOSTで送信した検証では、Accept に text/vnd.turbo-stream.html が加わりました。そのため respond_to で format.turbo_stream を返せば、フォームのあるフレームと一覧の件数など、複数箇所を1回の応答で更新できます。Streamsの書き方はTurboとは?Hotwireの高速化フレームワークの仕組みとRailsでの使い方【Turbo 8.0.23】で扱っています。scaffoldを使った雛形の作り方はRailsジェネレータ(rails generate)の使い方|モデル・scaffold・カスタム生成までが参考になります。

Turbo Framesの遅延読み込み:loading属性とsrcの設定

src を持つフレームは、ページの表示後に別リクエストで中身を取りに行きます。既定の loading="eager" では接続した時点ですぐ読み込み、loading="lazy" では表示領域に入るまで待ちます。

<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
  <p>コメントを読み込んでいます…</p>
<% end %>

8.0.23の実装は IntersectionObserver をオプション無しで生成しているため、rootMargin による先読みはありません。IntersectionObserverをモックに置き換えたjsdomの検証では、交差を手動通知するまで fetch は0回で、通知した直後に1回だけ呼ばれ、ブロック内の「読み込んでいます」が応答の中身に置き換わりました。スクロールしてから読み込みが始まるので、画面に入る少し前から読み込みたいなら、別要素を監視するStimulusコントローラーで、先読みのタイミングに loading="eager" へ切り替えて src を設定するなど、自分で仕組みを足す必要があります。

公式ハンドブックは使いどころとして <details> の中やモーダルなど最初は隠れている領域を挙げ、ユーザーごとに変わる部分だけをフレームに切り出せばページの残りを共有キャッシュに載せられる、とキャッシュ面の利点も説明しています。逆に、検索結果に載せたい本文やページの主題は初期HTMLに置き、遅延フレームはコメント欄・関連情報・通知のような補助領域に限るのが安全です。

Turbo Framesの遷移先・履歴制御:target・data-turbo-frame・data-turbo-action

フレーム内のリンクとフォームは、既定ではそのフレームだけを書き換えます。遷移先を変える方法は3つあり、指定する場所が違います。

指定 付ける場所 効果
target="_top" turbo-frame フレーム内の全リンクでページ全体を遷移
target="他のid" turbo-frame 別のフレームを書き換える
data-turbo-frame="_top" a・form・button その要素だけページ全体を遷移
data-turbo-frame="id" a・form・button フレーム外の要素から特定フレームを書き換える
data-turbo-frame="_self" a・form フレームのtarget指定を打ち消して自分のフレームへ

8.0.23のソースでは、data-turbo-frame は送信ボタン、フォームやリンク、フレームの target の順に探され、最初に見つかった値が使われます。"_parent" も解釈され、1つ外側の <turbo-frame> を指します。

フレーム内の遷移は既定でURLを変えません。ページ送りのように、再読み込みやURL共有で同じ状態に戻したい場合は data-turbo-action="advance" を付けると、フレームの src とブラウザのURLの両方が更新されます。

<turbo-frame id="articles" data-turbo-action="advance">
  <!-- 記事一覧 -->
  <a href="/articles?page=2" rel="next">次のページ</a>
</turbo-frame>

このとき公式ハンドブックが注意しているとおり、/articles?page=2 を直接開いたときに2ページ目を描画するのはアプリ側の責任です。フレームの応答だけ2ページ目に対応させ、ページ全体の描画が page パラメーターを無視していると、再読み込みで1ページ目に戻ります。

Content missingの原因と対処

応答HTMLに同じidのフレームが無いと、Turbo 8.0.23はフレームの中身を <strong class="turbo-frame-error">Content missing</strong> に置き換え、次のメッセージで例外を投げます。jsdomでidの違うフレームを返したときも、この文言がそのまま出ました。

The response (200) did not contain the expected <turbo-frame id="detail"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.

原因はほぼ次の4つに分かれます。

  • idの不一致:turbo_frame_tag post, "comments" と手書きのidの混在、パーシャルの受け取り変数違いなど
  • 成功時のリダイレクト先にフレームが無い:前章の redirect_to のケース
  • ログインページへのリダイレクト:セッション切れで認証ページが返り、そこにはフレームが無い
  • エラーページ:500や404の応答は通常アプリのレイアウトで描かれ、フレームを含まない

ログインページやエラーページのように、フレームの中ではなくページ全体で見せたい応答には、<meta name="turbo-visit-control" content="reload"> を入れます。turbo-railsでは、そのビューで turbo_page_requires_reload を呼べば同じタグが head に入ります。この指定がある応答は、フレームからのリクエストでもページ全体の遷移に切り替わります。

個別に扱いたい場合は turbo:frame-missing を受け取ります。このイベントは取り消し可能で、preventDefault() すると既定の処理(Content missingの表示と例外)が止まります。jsdomでも、取り消したフレームは元の中身が残りました。応答をそのままページ遷移に使うなら、event.detail.visit に Response を渡せます。

document.addEventListener("turbo:frame-missing", (event) => {
  const { response, visit } = event.detail
  // "/login" は自分のアプリの認証ページのパスに置き換える
  if (response.redirected && new URL(response.url).pathname === "/login") {
    event.preventDefault()
    visit(response)
  }
})

すべてのフレームでこの処理を有効にすると、idの書き間違いまで黙ってページ遷移に化けて見逃します。適用はログイン切れなどリダイレクトを伴う応答に限り、idの不一致はContent missingのまま表に出して直すほうが、不具合を早く見つけられます。

フレームの再読み込みと描画の制御

src を持つフレームは、要素の reload() を呼ぶと同じURLを取り直します。このとき refresh="morph" が付いていれば、中身を丸ごと入れ替えずにIdiomorphで差分だけを当てます。Turbo 8のページリフレッシュでモーフィングを使っている場合も、src と refresh="morph" を持つフレームは同じ方法で取り直されます。

<turbo-frame id="notifications" src="/notifications" refresh="morph"></turbo-frame>

<script>
  document.getElementById("notifications").reload()
</script>

描画そのものを差し替えたいときは turbo:before-frame-render を使います。event.detail.render を上書きすれば独自の差し替え処理に、preventDefault() したあと event.detail.resume() を呼べば退場アニメーションを挟んでから描画、といった制御ができます。ページ全体のモーフィングとの関係は、前述のTurboの記事にあるTurbo 8の章で扱っています。

Turbo Frames・Turbo Streams・Turbo Driveの使い分け

3つは排他ではなく、Turbo Driveが有効な画面の一部をFramesで区切り、必要な箇所だけStreamsで更新する、という重ね方をします。どれを使うかは「1回の操作で何か所を、誰の操作をきっかけに更新するか」で決まります。

観点 Turbo Drive Turbo Frames Turbo Streams
更新範囲 body全体 1つのフレーム 複数のid要素
きっかけ リンク・フォーム フレーム内の操作・src フォーム応答・WebSocket・SSE
サーバーの応答 通常のHTML 同じidを含むHTML turbo-stream要素
URLの変化 あり 既定なし(advanceで可) なし

インライン編集・タブ・ページ送り・遅延表示のように「操作した場所だけが変わる」画面はFramesで足ります。保存したら一覧と件数とフラッシュを同時に変えたい、他のユーザーの操作をリアルタイムで反映したい、となった時点でStreamsを足します。リアルタイム配信の土台はAction Cableとは?Rails 8.1のWebSocket実装とSolid Cable・Redisの選び方、Hotwire全体での位置づけはHotwireとは?Turbo・Stimulus・Hotwire Nativeの役割とRails 8.1での使い方にまとめています。

Turbo Framesで設計を誤りやすい場面

Framesは「サーバーがHTMLを返し、同じidで差し替える」以上のことをしません。この前提から外れる要件では、無理にFramesで組むと分岐とidの管理が増えていきます。

  • 1つの操作で離れた複数箇所が変わる画面:フレームを入れ子にして target を張り巡らせるより、Streamsで必要な要素を名指しして更新するほうが追いやすい
  • 入力途中の状態を保ちたいフォーム:フレームの差し替えは既定で中身を丸ごと置き換えるため、差し替えたフレーム内の未保存の入力は残らない。複数ステップの入力はフレームを分けず1つのフォームにまとめる
  • ブラウザの戻るで状態を戻したい一覧:data-turbo-action="advance" を付け、URLからページ全体を再現できる作りにしてから使う。付け忘れると、戻るで一覧の外まで戻ってしまう
  • クライアント側の状態が主役のUI:ドラッグ操作中の並び順やキャンバス描画のように、サーバー往復を待てない操作はStimulusやReact側で持つ

HTMLを差し替える発想はhtmxとも共通しますが、htmxは hx-target で差し替え先を明示する(親要素に書いて子へ継承させることもできる)のに対し、Turbo Framesは応答側に同じidのフレームを置くことを規約にしています。Rails以外のサーバーで同じ発想を取りたい場合の比較材料として、htmxの使い方:hx-属性の実装手順からFastAPI連携まで解説【2026年版】も参考になります。

Turbo Framesに関するよくある質問

Turbo Framesとiframeの違いは?

<turbo-frame> は同じ文書の中のカスタム要素で、CSSもJavaScriptも親ページと共有します。サーバーから取ってきたHTMLのうち同じidの部分をDOMに差し込むだけで、別の文書や別オリジンのページを隔離して表示する <iframe> とは仕組みが異なります。

data-turbo-frameは何に使う?

リンク・フォーム・送信ボタンごとに遷移先を指定する属性です。_top でページ全体、フレームのidでそのフレームを書き換え、フレームの外にあるリンクから特定のフレームだけを更新するときにも使えます。

Rails以外でもTurbo Framesは使える?

使えます。npmの @hotwired/turbo を読み込めば <turbo-frame> が動き、サーバー側は同じidのフレームを含むHTMLを返すだけで足ります。turbo_frame_tag やレイアウトの差し替え、turbo_page_requires_reload はturbo-railsの機能なので、他のフレームワークでは自前で書くか、各フレームワーク向けの統合パッケージを使います。

フレーム内のリンクを押してもURLが変わらないのはなぜ?

既定ではフレームの src だけが変わり、ブラウザのURLと履歴には触れないためです。反映したい場合はフレームかリンクに data-turbo-action="advance" を付けます。

idは一致しているのにContent missingになるのは?

最終的に返ってきた応答を確認してください。フォーム送信後のリダイレクト先、セッション切れで返るログインページ、500のエラーページは、送信元のフレームを含まないことが多くあります。ブラウザの開発者ツールで Turbo-Frame ヘッダー付きリクエストの応答本文を開き、同じidの <turbo-frame> が入っているかを見ると原因を絞れます。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2026.10.08 テックブログ 大阪公立大学のランサムウェア被害と仮想化基盤の停止|全授業休講に至った経緯とバックアップを守る設定
  3. 2024.11.08 テックブログ OpenAPI GeneratorでJavaコードを自動生成する方法|CLI導入からSpring・ライブラリ選択まで
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順
  5. 2026.10.09 テックブログ 京王電鉄のランサムウェア被害とグループ共通基盤:決済・ポイント・予約が止まった範囲と遮断の初動

RELATED POSTS 関連記事

目次