RailsのTurbo Framesで無限スクロール|JSなしで次ページを自動読み込み【Rails 8対応】

Turbo Framesによる無限スクロールの流れ。末尾の見えないフレーム(loading: lazy)が画面に入ると次ページを自動取得し、一覧の下に積み重ねる様子
  • このエントリーをはてなブックマークに追加

スポンサーリンク

「一覧ページを無限スクロールにしたい。でもそのためにJavaScriptのライブラリを入れて、IntersectionObserverを書いて……というのは正直おっくう」——そんなふうに感じたことはありませんか?

じつは、RailsのHotwire標準のTurbo Framesを使えば、専用のJavaScriptをほとんど書かずに無限スクロール(無限ページネーション)が作れます。ページ末尾の「見えないフレーム」が画面に入った瞬間に次ページを取りにいく、という仕組みです。

この記事では、自動で読み込む方式と、「もっと見る」ボタンにも流用できる方式の2つを、使い分けの判断基準つきで解説します。コードはすべてコピペできる形で載せます。TurboはRails 7以降は標準添付(turbo-rails)なので、Rails 7でも8でも追加のgemなしで動きます。

この記事で作るもの(完成イメージ)

今回作るのは、次のような動きの記事一覧です。

  • 最初は1ページ分(たとえば10件)だけ表示される
  • ページ末尾までスクロールすると、次のページが自動で下に積まれる
  • 最後のページまで来たら、それ以上は読み込まれず静かに止まる
Turbo Framesによる無限スクロールの流れ。末尾にある見えないフレームが画面に入ると次ページを自動取得し、一覧の下に積み重ねる様子を示した図
ページ末尾の「見えないフレーム」が画面に入ると、次ページを自動で取得して下に積む

ポイントは、Turboが「同じIDのフレームを見つけて中身を差し替える」という性質を持っていることです。これを利用すると、ページネーションのリンクを踏まなくても、次ページのHTMLを裏で取ってきて一覧に継ぎ足せます。順番に見ていきましょう。

無限スクロールの2つの実装方式と使い分け

Turboで無限スクロールを作る方法は、大きく2つあります。どちらを選ぶかは「完全自動にしたいか」「DOMの増え方を抑えたいか」で決めるのが分かりやすいです。

  • 方式A:loading: :lazy フレーム方式 — 一覧の末尾に次ページ用の空フレームを置き、画面に入ると自動ロード。純粋なHTMLだけで完結する
  • 方式B:Turbo Stream で append する方式 — 次ページ取得時に turbo_stream.append で項目を追加。「もっと見る」ボタンにそのまま流用できる

違いを表で整理します。

観点方式A(lazyフレーム)方式B(Turbo Stream)
専用JS不要不要
スクロールで自動◎(標準で自動)△(ボタン向き。自動化は別途)
「もっと見る」ボタン△◎(そのまま流用できる)
DOM構造フレームが入れ子に深くなるフラットに積める
実装の手数少ない(ビューだけ)やや多い(.turbo_stream.erbが要る)

ざっくりの指針はこうです。「スクロールで勝手に続いてほしい」なら方式A、「もっと見るボタンにしたい・DOMをフラットに保ちたい」なら方式B。まずは共通の下準備(ページネーション)から進めます。

下準備:Pagyで「次ページがあるか」を判定する

無限スクロールに必要なのは「次のページがあるか/そのページ番号は何か」だけです。総件数を数える必要はありません。そこで今回は、Pagyのcountlessモードを使います。これは「件数 + 1件だけ余分に取得して次ページの有無を判定する」仕組みで、重い COUNT クエリを1回分まるごと省けます。

まず初期化ファイルで countless エクストラを読み込みます。

# config/initializers/pagy.rb
require "pagy/extras/countless"

コントローラは、通常の pagy の代わりに pagy_countless を呼ぶだけです。1ページ10件にしてみます。

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @pagy, @posts = pagy_countless(
      Post.order(created_at: :desc),
      limit: 10
    )
  end
end

ここで limit: 10 が「1ページあたりの件数」です。Pagy 9系からキーワードが items: → limit: に変わっている点に注意してください(Pagy 8以前を使っている場合は items: 10 と書きます)。

これで、ビューから @pagy.next を見れば「次ページの番号(無ければ nil)」が取れます。この @pagy.next が、以降の実装の心臓部です。ルーティングは一覧アクションがあれば十分です。

# config/routes.rb
resources :posts, only: :index

一覧の1件分を描画する部分テンプレートも用意しておきます。どちらの方式でも共通で使います。

<%# app/views/posts/_post.html.erb %>
<article class="post">
  <h2><%= post.title %></h2>
  <p><%= truncate(post.body, length: 120) %></p>
</article>

ページネーションそのものの基礎(PagyとKaminariの違いや選び方)はRailsのページネーション入門で解説しています。Pagyをまだ入れていない方は先にそちらを読むとスムーズです。

方式A:loading: :lazy で自動読み込み

まずは、専用JSもTurbo Streamも使わない、いちばんシンプルな方式です。やることは1つだけ。一覧の末尾に「次ページを指す空フレーム」を置き、loading: :lazy を付けることです。

<%# app/views/posts/index.html.erb %>
<h1>記事一覧</h1>

<div id="posts">
  <%= turbo_frame_tag "posts_page_#{@pagy.page}" do %>
    <%= render @posts %>

    <% if @pagy.next %>
      <%= turbo_frame_tag "posts_page_#{@pagy.next}",
            loading: :lazy,
            src: posts_path(page: @pagy.next) do %>
        <p class="loading">読み込み中...</p>
      <% end %>
    <% end %>
  <% end %>
</div>

仕組みを順に追うとこうなります。

  1. 最初の表示では、1ページ目の記事(render @posts)と、2ページ目を指す空フレーム(posts_page_2)が描画される
  2. スクロールして posts_page_2 が画面に入ると、Turboが /posts?page=2 を裏で取得する
  3. サーバーは同じ index.html.erb を @pagy.page == 2 の状態で描画し、同じIDのフレーム posts_page_2 を含むHTMLを返す
  4. Turboはその同IDフレームの中身(2ページ目の記事+3ページ目の空フレーム)で、空フレームを置き換える
方式Aの仕組み図。末尾の空フレームposts_page_2が画面に入るとGET posts?page=2が飛び、レスポンス内の同IDフレームの中身(2ページ目の記事と次の空フレームposts_page_3)で置き換わる流れ
末尾の空フレームが「同じIDのフレーム」で中身ごと差し替わり、次ページが継ぎ足される

この「2ページ目のフレームの中に、さらに3ページ目のフレームを入れる」という入れ子が、無限に続いていくイメージです。@pagy.next が nil になる最後のページでは空フレームを出さないので、そこで自然に止まります。

loading: :lazy は「フレームが画面に見える位置に入るまでリクエストを遅らせる」指定です。この性質を、重い部分を後から出す用途に使うのがTurbo Framesで遅延読み込みの記事でした。無限スクロールは、その lazy を「ページ末尾」に置いて繰り返しているだけ、と捉えると理解しやすいはずです。

「読み込み中…」の部分は、次ページを取得しているあいだだけ見えるプレースホルダです。ここをスピナーやスケルトンにすると、待ち時間の印象が良くなります。

方式B:Turbo Stream で append する

方式Aは手軽ですが、ページをめくるほどフレームが入れ子に深くなります。DOMをフラットに保ちたい、あるいは「もっと見る」ボタンにしたいときは、Turbo Streamで一覧に追記する方式が向いています。

一覧ビューは、記事を入れる #posts と、ページ送り用の #pager を分けて置きます。

<%# app/views/posts/index.html.erb %>
<h1>記事一覧</h1>

<div id="posts">
  <%= render @posts %>
</div>

<%= render "pager" %>

_pager 部分テンプレートには、「もっと見る」リンクを置きます。ポイントは data: { turbo_stream: true } です。Turboは既定ではGETリクエストでTurbo Stream形式(text/vnd.turbo-stream.html)を受け付けません。この指定を付けることで、GETリンクでもTurbo Streamのレスポンスを受け取れるようになります。

<%# app/views/posts/_pager.html.erb %>
<div id="pager">
  <% if @pagy.next %>
    <%= link_to "もっと見る",
          posts_path(page: @pagy.next),
          data: { turbo_stream: true },
          class: "load-more" %>
  <% end %>
</div>

コントローラは方式Aと同じ pagy_countless のままでかまいません。あとは、Turbo Streamのレスポンス用テンプレートを追加します。記事を #posts に追記(append)し、#pager を次ページ用に差し替える(replace)だけです。

<%# app/views/posts/index.turbo_stream.erb %>
<%= turbo_stream.append "posts" do %>
  <%= render @posts %>
<% end %>

<%= turbo_stream.replace "pager" do %>
  <%= render "pager" %>
<% end %>

これで、「もっと見る」を押すと次ページの記事が一覧の下にフラットに追記され、ボタン自身はさらに次のページを指すものに置き換わります。最後のページまで来ると @pagy.next が nil になり、#pager の中身が空になってボタンが消えます。

この方式を「スクロールで自動発火」にしたい場合は、ボタンを画面に入ったタイミングで自動クリックする小さなStimulusコントローラを足すのが定番です。自動の手軽さを取るなら方式A、フラットな構造と拡張性を取るなら方式B、と覚えておけば迷いません。

レスポンスを軽くする(方式A向けの最適化)

方式Aでは、次ページの取得リクエストにも通常のレイアウト(ヘッダー・フッター・ナビ)が丸ごと乗ってしまいます。Turboは同IDのフレーム部分しか使わないので、レイアウトを外しておくと転送量を減らせます。これは1行で対応できます。

def index
  @pagy, @posts = pagy_countless(Post.order(created_at: :desc), limit: 10)
  # フレームからの読み込みならレイアウトを外して軽くする
  render layout: false if turbo_frame_request?
end

turbo_frame_request? は「Turbo Frameからのリクエストかどうか」を判定するヘルパーです。これで、2ページ目以降の取得ではヘッダー等を再描画せず、必要な記事フレームだけを返せます。方式B(Turbo Stream)はそもそもレイアウトが適用されないので、この対策は不要です。

なお、一覧で post.user のような関連を表示するなら、includes でN+1を潰しておくのも忘れずに。詳しくはRailsのN+1問題入門を参照してください。無限スクロールは1リクエストあたりの件数が少ないぶん見落としがちですが、ページを重ねるほど効いてきます。

よくあるつまずきと対処

① 次ページが差し込まれない(フレームIDの不一致)

方式Aでいちばん多いのがこれです。リクエストした空フレームのIDと、返すHTMLに含まれるフレームのIDが一致していないと、Turboは差し替え先を見つけられません。

今回は posts_page_#{@pagy.page} と posts_page_#{@pagy.next} を使い、リクエスト側(next)と応答側(page)のページ番号が噛み合うように組んでいます。IDの組み立て式を変えるときは、両側がズレないか必ず確認してください。

② スクロールしても発火しない

loading: :lazy のフレームは「画面内に入ったら」読み込まれます。裏を返すと、ページ全体が短くて空フレームが最初から画面内にある場合は、スクロール前に即ロードされます(これは正常動作です)。

逆に「まったく発火しない」ときは、一覧を囲む要素に height 固定+ overflow: hidden がかかっていて、フレームが可視領域に入れないケースを疑います。まずは余計な overflow を外して確認しましょう。

③ 最後のページで空フレームが出続ける/二重読み込み

@pagy.next の nil 判定が抜けていると、最後のページでも空フレームを出し続け、存在しないページを叩いてしまいます。<% if @pagy.next %> で必ず囲むのが対策です。今回のコードはこの判定を入れてあります。

④ 「もっと見る」でページ全体に遷移してしまう(方式B)

方式Bで data: { turbo_stream: true } を付け忘れると、GETリンクがTurbo Streamとして扱われず、普通のページ遷移になってしまいます。リンクに data-turbo-stream が出力されているかを、ブラウザの開発者ツールで確認してください。

⑤ スクロールするほど重くなる

無限スクロールの宿命として、読み込むほどDOMの要素が増え続けます。方式Aはフレームの入れ子も深くなります。数百件を超えて積む可能性があるなら、一定件数で「もっと見る」ボタンに切り替える、または画面外に出た古い要素を間引く、といった設計を検討しましょう。

まとめ

Turbo Framesを使えば、専用のJavaScriptライブラリなしで無限スクロールが作れます。ポイントを振り返ります。

  • 下準備は pagy_countless。COUNT を省きつつ @pagy.next で次ページ有無を判定する
  • 方式A(loading: :lazy):末尾に次ページ用の空フレームを置くだけ。スクロールで自動。手軽だがDOMが入れ子に深くなる
  • 方式B(Turbo Stream):append でフラットに積む。「もっと見る」ボタンに流用できる
  • 方式Aは turbo_frame_request? でレイアウトを外すと軽くなる
  • @pagy.next の nil 判定を忘れず、最後のページで止める

まずは方式Aを一覧ページに置いてみて、スクロールで次ページが継ぎ足される動きを体験してみてください。「もっと見る」にしたくなったら方式Bへ、という順で育てていくのがおすすめです。Turboの「同IDフレームを差し替える」感覚は、遅延読み込みとまったく同じなので、両方を押さえるとHotwireの使いどころがぐっと広がります。

この記事が役に立ったら、Xもフォローしてください

CTO・取締役として実際にやっている「AIをチームの仕組みにする方法(Claude Code活用・AX)」と、Rails・Tailwindの実務Tipsを発信しています。

@toaru_kaihatsu をフォローする →

スポンサーリンク

  • このエントリーをはてなブックマークに追加