👀

Turboで「もっとみる」ページネーション

3 min read

以下は

  • @hotwired/turbo: 7.0.0-beta.8

を前提としています。

Turboは、Tubolinks + Stimulusでのパターンが綺麗に取り込まれていて概ね快適です。
ただ、「もっとみる」ページネーションはまだTurbo内で綺麗に実装する道具が揃っていません(問題提起はあったのですが、色々あり無くなってしまいました)。今のTurboで「もっとみる」ページネーションをどう実装するか考えたので記事にしてみました。

現状ではTurbo Framesだけでは内容の全入れ替えにしか対応していません。なのでtarget="append" なTurbo Streamsが使いたいところですが、Turboが自動でTurbo Streamsのレスポンスを受け入れる(Accept: text/vnd.turbo-stream.html ヘッダをつける)のは副作用のあるリクエストの場合のみです。この制約のもとでどうするか。

採用した方針

結論から言うと以下のようにしました。

  • 自分でJavaScriptから Accept: text/vnd.turbo-stream.htmlをつけた GET リクエストを投げる
    • 結果を Turbo.renderStreamMessage に投げる
  • 2ページ目以降の場合にのみTurbo Streamsのレスポンスを返す
    • 副作用のあるリクエストの後に、このエンドポイントにリダイレクトされたときに意図せずTurbo Streamsのレスポンスが帰ってしまうことがあったため

何を使うにしても基本は同じだと思いますが、Railsでのコード例としては以下のような感じです。

自分でJavaScriptから Accept: text/vnd.turbo-stream.htmlをつけた GET リクエストを投げる

app/views/application/_load_more.html.erb
<div 
  id="load-more"
  data-controller="paginated-resource"  
>
  <%= link_to_next_page page_scope, "もっとみる", data: { action: "paginated-resource#load" } %>
</div>
paginated_resource_controller.ts
import { Controller } from "stimulus";
import { renderStreamMessage } from "@hotwired/turbo";

export default class extends Controller {
  element: HTMLDivElement
  loading: boolean = false;

  async load(e) {
    e.preventDefault();
    if(this.loading) return;

    const message = await this.request(e);
    if(message) renderStreamMessage(message);
  }

  async request(e): Promise<string | null> {
    try {
      this.loading = true;
      // 必要に応じて表示を変える
      const response = await fetch(e.target.href, {
        headers: {
          "Accept": "text/vnd.turbo-stream.html",
        },
      });
      const message = await response.text();
      return message;
    } catch(e) {
      console.error(e);
      return null;
    } finally {
      // 必要に応じて表示を戻す
      this.loading = false;
    }
  }
}

2ページ目以降の場合にのみTurbo Streamsのレスポンスを返す

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.order(id: :desc).page(params[:page])
    respond_with_paginated_stream(page_scope: @posts)
  end
  
  #... 
  
  private
  
  def respond_with_paginated_stream(action = action_name, page_scope:)
    respond_to do |format|
      format.html { render action }
      format.turbo_stream { render action } if page_scope.current_page != 1
    end
  end
end
app/views/posts/index.turbo_stream.erb
<%= turbo_stream.append "posts" do %>
  <%= render @posts %>
<% end %>

<%= turbo_stream.replace "load-more" do %>
  <%= render "application/load_more", page_scope: @posts %>
<% end %>

採用しなかった方針

以下採用しなかった方針も一応書いておきます。

採用しなかった方針1: Turbo Framesの入れ子

How to paginate items using Turbo - DEV Community 👩‍💻👨‍💻でのアプローチです。

iページを以下のように返していくことでページネーションを実現します。

  • turbo-frame#page-#{i}
    • 要素
    • turbo-frame#page-#{i+1}
      • i+1 ページへのリンク

非常に面白い!ただ、どんどん深くなる入れ子構造が強制されることが嫌です。例えばリストやテーブルをセマンティックに書くことはできません。

採用しなかった方針2: POST /**/page/:page のような「ページを作成する」エンドポイントにする

自然にTurbo Streamsが使えるようにエンドポイント側を変える方針です。
副作用がなくても POST にしなければいけないし、一般に公開されている場所ではクローラにクローリングして欲しいので選びませんでした。