Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第24章 ページネーションと無限スクロール

この章のねらい

タスクが増えると、一覧を一度に全部出すわけにはいきません。この章では、件数の多い一覧を分割して見せる方法を、3 段階で学びます。

通常のページネーション、「もっと読む」ボタンによる追加読み込み、そして無限スクロールです。それぞれに向き不向きがあり、どの Hotwire の道具を使うかも変わります。

24.1 完成イメージ

  • ページ送り … ?page=2 のようにページを切り替える、いちばん基本的な形
  • もっと読む … ボタンを押すと、続きが一覧の末尾に追加される形
  • 無限スクロール … 一覧の末尾までスクロールすると、自動で続きが読み込まれる形

下に行くほど「途切れず読める」一方で、操作性やアクセシビリティの注意が増えます。

24.2 この章の選択

3 段階で、使う道具が変わります。

  • ページ送りは、一覧を別ページの内容で置換します。Turbo Frames が向きます。
  • もっと読むは、続きを末尾に追記します。Turbo Streams の append が向きます。
  • 無限スクロールは、「末尾が見えた」ことを検知して、もっと読むを自動で押します。検知は Stimulus が担います。

置換は Frames、追記は Streams、検知は Stimulus。第7部の判断軸が、ここでも効きます。

ページネーション3段階の比較。ページ送り(Frames で置換)・もっと読む(Streams で追記)・無限スクロール(Stimulus で検知+追記)を、URL 再現性と a11y の観点で並べた比較図

24.3 通常のページネーションを作る

まず、サーバー側でページごとに区切ります。ここでは仕組みを示すため手で書きますが、実務では Pagy や Kaminari などの gem を使うのが普通です。

app/controllers/tasks_controller.rbindexPER_PAGE はコントローラのクラス定数として定義しておきます)

PER_PAGE = 20

def index
  @page = [params[:page].to_i, 1].max
  scope = Task.order(:id)
  @tasks = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
  @next_page = @page + 1 if scope.count > @page * PER_PAGE
end

@next_page は、次のページがあるときだけ値が入ります。最終ページでは nil です。

ビューでは、一覧と「次へ」のリンクを出します。

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

<% if @next_page %>
  <%= link_to "次へ", tasks_path(page: @next_page) %>
<% end %>

この時点では、ページを切り替えるとページ全体が visit されます。

24.4 Frame 内ページネーションと data-turbo-action

一覧とページ送りだけを差し替えるために、frame で囲みます。第23章の検索と同じ考え方です。

<%= turbo_frame_tag "task_list", data: { turbo_action: "advance" } do %>
  <div id="tasks">
    <%= render @tasks %>
  </div>

  <% if @next_page %>
    <%= link_to "次へ", tasks_path(page: @next_page) %>
  <% end %>
<% end %>

ページ送りのリンクは frame の中にあるので、クリックすると task_list frame だけが次ページの内容に置き換わります。data-turbo-action="advance" を付けてあるので、URL も ?page=2 に変わり、リロードや共有、戻る操作に耐えます。

これが「置換」型のページネーションです。一覧をまるごと次ページに置き換えます。

24.5 「もっと読む」ボタンで append を使う

次は「追記」型です。ページを置き換えるのではなく、続きを末尾に足します。

「もっと読む」リンクを、Turbo Streams で応答させます。第15章で見たとおり、GET でも data-turbo-stream を付ければ Turbo Streams を受け取れます。

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

<div id="pagination">
  <% if @next_page %>
    <%= link_to "もっと読む", tasks_path(page: @next_page), data: { turbo_stream: true } %>
  <% end %>
</div>

controller は、turbo_stream 形式に応答します。

app/controllers/tasks_controller.rbindex に追記)

respond_to do |format|
  format.html
  format.turbo_stream
end

app/views/tasks/index.turbo_stream.erb

<%= turbo_stream.append "tasks", partial: "tasks/task", collection: @tasks %>
<%= turbo_stream.update "pagination" do %>
  <% if @next_page %>
    <%= link_to "もっと読む", tasks_path(page: @next_page), data: { turbo_stream: true } %>
  <% end %>
<% end %>

「もっと読む」を押すと、次ページのタスクが id="tasks" の末尾に append され、ボタン自体は次ページ用のボタンに update されます。最終ページでは @next_pagenil なので、ボタンが消えます。一覧は置き換わらず、下に伸びていきます。

24.6 IntersectionObserver で自動化する

無限スクロールは、この「もっと読む」を自動で押す形です。末尾が画面に見えたことを検知して、ボタンをクリックします。検知には、ブラウザの IntersectionObserver を使います。これを Stimulus controller で包みます。

app/javascript/controllers/infinite_scroll_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["button"]

  connect() {
    this.observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this.buttonTarget.click()
      }
    })
  }

  buttonTargetConnected(button) {
    this.observer.observe(button)
  }

  buttonTargetDisconnected(button) {
    this.observer.unobserve(button)
  }

  disconnect() {
    this.observer.disconnect()
  }
}

ここがこの章の肝です。「もっと読む」を押すと、24.5 のとおり update "pagination" でボタンが新しい要素に差し替わります。もし connect() で最初のボタンだけを監視していると、差し替え後は削除済みの旧ボタンを見続け、2 回目以降が発火しません。そこで、ターゲットが差し替わるたびに呼ばれる buttonTargetConnected / buttonTargetDisconnected を使い、新しいボタンを監視し直します。最終ページでボタンが消えれば、buttonTargetDisconnected で監視が外れ、自動読み込みも止まります。

<div id="pagination" data-controller="infinite-scroll">
  <% if @next_page %>
    <%= link_to "もっと読む", tasks_path(page: @next_page),
          data: { turbo_stream: true, infinite_scroll_target: "button" } %>
  <% end %>
</div>

ここで data-controller の値に注意します。ファイル名は infinite_scroll_controller.js ですが、識別子はアンダースコアがハイフンになり infinite-scroll です(第19章)。data-controller の値は変換されないので、"infinite-scroll" と書きます。一方、ターゲットの data: { infinite_scroll_target: "button" } は、Rails が属性名を data-infinite-scroll-target に変換するので、そのままで infinite-scroll controller のターゲットになります。

「もっと読む」ボタンが画面に見えると、IntersectionObserver が検知し、ボタンを click() します。クリックは 24.5 の append を起こすので、続きが自動で読み込まれます。disconnect() で監視を解除するのを忘れないでください(第22章)。

24.7 ボタンを残す理由

無限スクロールでも、24.6 のように「もっと読む」ボタンを土台に残すことを勧めます。自動読み込みは、その上に乗せる「おまけ」と考えます。

理由はアクセシビリティです。純粋な無限スクロールは、キーボードだけで操作するユーザーや、スクリーンリーダーのユーザーには扱いにくいものです。ボタンがあれば、スクロールに頼らず続きを読めますし、フォーカスも当てられます。自動読み込みは便利ですが、ボタンという確実な手段を奪ってはいけません。

24.8 URL とスクロール位置

追記型・無限スクロールには、URL とスクロール位置の弱点があります。

ページ送り(24.4)は ?page=2 が URL に残るので、リロードや共有で同じ位置に戻れます。一方、もっと読む・無限スクロールは、下に伸ばしているだけなので、リロードすると最初のページに戻ります。また、詳細へ移動してから戻ると、読み込んだ続きやスクロール位置が失われがちです。

「途切れず読める」ことと「URL で位置を再現できる」ことは、トレードオフです。共有・リロードを重視する一覧はページ送りに、流し読みを重視する一覧は追記型に、と使い分けます。

24.9 テスト

ページネーションのテストでは、次の 2 点を特に確かめます。

  • 追記に重複がないこと。「もっと読む」を押したとき、すでに表示済みのタスクが二重に出ないか。
  • 最終ページの終端。最後まで読み込んだら、「もっと読む」が消えるか。

System Test で、もっと読むを押して件数が増えること、最後にボタンが消えることを確認します。境界(ちょうど割り切れる件数、1 ページに満たない件数)も見ておくと安心です。

24.10 アンチパターン

  • フッターに永遠に到達できない。無限スクロールで、ページ下部のフッターやリンクに、いつまでもたどり着けなくなる。
  • 戻ると位置が失われる。詳細から戻ったとき、読み込んだ続きやスクロール位置が消える。
  • 監視の解除漏れIntersectionObserverdisconnect() で解除せず、二重に読み込む。

第24章では、ページネーションを置換・追記・検知の 3 段階で作り分けました。次の第25章では、フォームのバリデーションエラーと、その UX を扱います。第8章で予告した 422 の契約を、ここで本格的に使います。

参考資料