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.rb(index。PER_PAGE はコントローラのクラス定数として定義しておきます)

PER_PAGE = 20

def index
  @page = [params[:page].to_i, 1].max
  @task = Task.new
  scope = filtered_tasks
  @total_count = scope.count
  @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(request.query_parameters.merge(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(request.query_parameters.merge(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(request.query_parameters.merge(page: @next_page)), data: { turbo_stream: true, infinite_scroll_target: "button" } %>
  <% end %>
</div>

コントローラは、turbo_stream 形式に応答します。

app/controllers/tasks_controller.rb(index に追記)

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(request.query_parameters.merge(page: @next_page)), data: { turbo_stream: true, infinite_scroll_target: "button" } %>
  <% end %>
<% end %>

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

24.6 IntersectionObserver で自動化する

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

app/javascript/controllers/infinite_scroll_controller.js

app/javascript/controllers/infinite_scroll_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  static targets = ["button"]
  initialize() {
    this.observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting && entry.target.isConnected) {
          // 同じリンクの自動送信は一度だけ。失敗時は手動で再試行できる。
          this.observer.unobserve(entry.target)
          entry.target.click()
        }
      }
    })
  }
  buttonTargetConnected(button) { this.observer.observe(button) }
  buttonTargetDisconnected(button) { this.observer.unobserve(button) }
  disconnect() { this.observer.disconnect() }
}

buttonTargetConnected は初回の connect より先に呼ばれます。そのため observer は initialize で作ります。新旧のリンクを同じ partial から描き、更新後にも target 属性を残します。

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

<div id="pagination" data-controller="infinite-scroll">
  <% if @next_page %>
    <%= link_to "もっと読む", tasks_path(request.query_parameters.merge(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 コントローラのターゲットになります。

「もっと読む」ボタンが画面に見えると、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 アンチパターン

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

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

24.11 完成版への置き換え

ここまでのページ送り・追記型・自動読み込みは、同じ場所へ重ねて追加するコードではなく、表示方式の選択肢です。完成版では mode=pages / more / infinite で切り替えます。以下は第23章の検索、CRUD、モーダルの分岐も含む統合版です。第25・26章ではフォームとモーダル側を読み解きます。

app/controllers/tasks_controller.rb

class TasksController < ApplicationController
  before_action :set_task, only: %i[show edit update destroy]
  PER_PAGE = 20

  def index
    @task = Task.new
    scope = filtered_tasks
    @total_count = scope.count
    @page = [ params[:page].to_i, 1 ].max
    @paged_tasks = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
    @next_page = @page + 1 if @total_count > @page * PER_PAGE
    @mode = %w[more infinite].include?(params[:mode]) ? params[:mode] : "pages"
    respond_to do |format|
      format.html
      format.turbo_stream do
        if @mode != "pages" && params[:page].present?
          render :index
        else
          render :index, formats: [ :html ], content_type: "text/html"
        end
      end
    end
  end

  def show
    @comment = Comment.new
  end

  def new
    @task = Task.new
  end

  def edit
  end

  def create
    @task = Task.new(task_params)
    if @task.save
      respond_to do |format|
        format.turbo_stream { flash.now[:notice] = "タスクを作成しました。" }
        format.html { redirect_to @task, notice: "タスクを作成しました。", status: :see_other }
      end
    elsif turbo_frame_request_id == "modal"
      render :new, formats: [ :html ], status: :unprocessable_entity
    elsif params[:form_context] == "inline" && request.format.turbo_stream?
      render turbo_stream: turbo_stream.update("new_task_form", partial: "tasks/form",
        locals: { task: @task, context: "inline" }), status: :unprocessable_entity
    else
      render :new, formats: [ :html ], status: :unprocessable_entity
    end
  end

  def update
    if @task.update(task_params)
      respond_to do |format|
        format.turbo_stream { flash.now[:notice] = "タスクを更新しました。" }
        format.html { redirect_to @task, notice: "タスクを更新しました。", status: :see_other }
      end
    else
      render :edit, formats: [ :html ], status: :unprocessable_entity
    end
  end

  def destroy
    @task.destroy!
    respond_to do |format|
      format.turbo_stream { flash.now[:notice] = "タスクを削除しました。" }
      format.html { redirect_to tasks_path, notice: "タスクを削除しました。", status: :see_other }
    end
  end

  private

  def set_task
    @task = Task.find(params[:id])
  end

  def task_params
    params.expect(task: [ :project_id, :title, :description, :status, :due_on, :assignee_id, tag_ids: [] ])
  end

  def filtered_tasks
    scope = Task.includes(:assignee, :tags).order(id: :desc)
    if params[:q].present?
      scope = scope.where("title LIKE ? ESCAPE ?", "%#{Task.sanitize_sql_like(params[:q])}%", "\\")
    end
    scope = scope.where(status: params[:status]) if params[:status].present?
    scope = scope.where(project_id: params[:project_id]) if params[:project_id].present?
    scope = scope.joins(:taggings).where(taggings: { tag_id: params[:tag_id] }).distinct if params[:tag_id].present?
    scope
  end
end

app/views/tasks/index.html.erb

<% content_for :title, "タスク" %>
<h1>タスク</h1>
<%= turbo_stream_from :tasks %>
<div id="task_changes"></div>
<p><%= link_to "新規作成(モーダル)", new_task_path, id: "new_task_modal_link", data: { turbo_frame: "modal" } %></p>
<section class="new-task">
  <h2>新規タスク</h2>
  <div id="new_task_form"><%= render "form", task: @task, context: "inline" %></div>
</section>
<%= form_with url: tasks_path, method: :get,
  data: { turbo_frame: "task_list", turbo_action: "replace", controller: "search",
    action: "input->search#submit change->search#submit compositionstart->search#startComposition compositionend->search#endComposition" } do |form| %>
  <%= form.label :q, "タイトルで検索" %>
  <%= form.search_field :q, value: params[:q] %>
  <%= form.label :status, "ステータス" %>
  <%= form.select :status, Task.statuses.keys, { include_blank: "すべて", selected: params[:status] } %>
  <%= form.label :project_id, "プロジェクト" %>
  <%= form.collection_select :project_id, Project.order(:name), :id, :name, { include_blank: "すべて", selected: params[:project_id] } %>
  <%= form.label :tag_id, "タグ" %>
  <%= form.collection_select :tag_id, Tag.order(:name), :id, :name, { include_blank: "すべて", selected: params[:tag_id] } %>
  <%= form.hidden_field :mode, value: @mode %>
  <%= form.submit "検索" %>
<% end %>
<nav aria-label="一覧の表示方式">
  <% { "pages" => "ページ送り", "more" => "もっと読む", "infinite" => "自動読み込み" }.each do |mode, label| %>
    <%= link_to label, tasks_path(request.query_parameters.except("page").merge(mode: mode)) %>
  <% end %>
</nav>
<section data-controller="task-list" data-mode="<%= @mode %>" data-action="tasks:received@document->task-list#changed turbo:frame-load->task-list#loaded">
  <p hidden data-task-list-target="notice" role="status">更新があります。編集中の内容を保存してから
    <button type="button" data-action="task-list#refresh">最新の一覧を表示</button>してください。追記型は先頭から表示します。
  </p>
  <%= turbo_frame_tag "task_list", data: { turbo_action: "advance", task_list_target: "frame" } do %>
    <p role="status" data-task-list-target="location" data-url="<%= request.fullpath %>"><span id="task_count"><%= @total_count %></span> 件該当(現在 <%= @page %> ページ)</p>
    <%= render "tasks", tasks: @paged_tasks %>
    <% if @mode == "pages" %>
      <nav aria-label="ページ送り">
        <%= link_to "前へ", tasks_path(request.query_parameters.merge(page: @page - 1)) if @page > 1 %>
        <%= link_to "次へ", tasks_path(request.query_parameters.merge(page: @next_page)) if @next_page %>
      </nav>
    <% else %>
      <div id="pagination" data-controller="<%= "infinite-scroll" if @mode == "infinite" %>">
        <%= render "pagination" %>
      </div>
    <% end %>
  <% end %>
</section>

app/views/tasks/_pagination.html.erb

<% if @next_page %>
  <%= link_to "もっと読む", tasks_path(request.query_parameters.merge(page: @next_page)),
    data: { turbo_stream: true, infinite_scroll_target: "button" } %>
<% end %>

app/views/tasks/index.turbo_stream.erb

<%= turbo_stream.append "tasks", partial: "tasks/task", collection: @paged_tasks %>
<%= turbo_stream.update "pagination", partial: "tasks/pagination" %>

app/views/tasks/create.turbo_stream.erb

<%= turbo_stream.update "new_task_form" do %>
  <%= render "form", task: Task.new, context: "inline" %>
<% end %>
<%= turbo_stream.update "modal" %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

app/views/tasks/update.turbo_stream.erb

<%= turbo_stream.replace @task %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

app/views/tasks/destroy.turbo_stream.erb

<%= turbo_stream.remove @task %>
<%= turbo_stream.update "flash", partial: "layouts/flash" %>
<%= turbo_stream.update "task_changes", partial: "tasks/change" %>

@total_count は全該当件数、@paged_tasks は現在のページの行です。変更通知後は一覧フレームを再取得し、件数・空状態・次ページの有無を同じ条件で再計算します。編集中と追記モードでは自動で全置換せず通知を出します。

offset 型は読み込みの途中でレコードが増減するとページ境界が動きます。静止したデータでの重複防止テストと、同時更新時の順序保証は別です。本番で厳密な連続性が必要ならカーソル型を検討します。

参考資料