kaminariで"ページネーションのhtml要素"を非同期に出す


「kaminari 非同期」で検索すると、ページネーションの"リンク先のページ"を非同期で表示する方法が出てきます。kaminariのREADMEで紹介しているのもコレです。
https://github.com/kaminari/kaminari

そうではなくて、"ページネーションのhtml要素"を非同期で後から出す方法の紹介です。
検索してもあまり出てこなかったんですよね。

(↑ページネーションのhtml要素)

Gemのバージョンはkaminari (1.2.1), rails (6.0.3.4)です。

なぜ非同期に?

普通にkaminariでページネーションの要素を出すときは、ビューで<%= paginate(@users) %>のように書きます。
でもこれ、最後のページが何ページ目なのか求めるために、「レコードの全件数を取得するクエリ」が流れるんです。

SELECT COUNT(*) FROM users;

この場合は軽そうですが、色々と検索条件を付けていくと重いクエリになりそうです。なのでページネーションの要素は非同期で表示させたい、というのが動機です。

実装

最初に全体像を載せます。Railsで、Userモデルのindexページ、という想定です。

routes.rb
resources :users
users_controller.rb
def index
  users = User.all.page(params[:page]).per(3)
  respond_to do |format|
    format.html { @users = users.without_count }
    format.json { render :json => view_context.paginate(users).gsub('.json', '') }
  end
end
users/index.html.erb
<div id="paginator"></div>
<script>
  document.addEventListener('DOMContentLoaded', () => {
    const path = location.pathname + '.json' + location.search;
    const paginator = document.querySelector('#paginator');
    fetch(path).then(response => response.text()).then(html => {
      paginator.insertAdjacentHTML('afterbegin', html);
    })
  })
</script>

解説

処理の流れを順に説明します。最初、usersコントローラーのindexアクションにhtmlのリクエストが来たら、モデルに.without_countメソッドを付けてからviewに渡します。

format.html { @users = users.without_count }

このメソッドをつけると、先程載せた「レコードの全件数を取得するクエリ」が発行されなくなります。
ページネーションの要素が付いてないhtmlが返され、同期処理は終わりです。

ここからが非同期です。返したhtml(内のjavascript)では、ページネーションの要素を非同期で要求します。
このときURLをlocation.pathname + '.json' + location.search;として、
「URLのパスの末尾をjsonに変えただけのURL」を送ります。元のパスが/users?age=10なら送るパスは/users.json?age=10です。

送られてきたリクエストは、同じくusersコントローラーのindexアクションに送られます。
URLに.jsonが付いているので、respond_toformat.jsonの方が呼ばれます。

format.json { render :json => view_context.paginate(users).gsub('.json', '') }

view_contextはビューのインスタンスを返すメソッドです( https://apidock.com/rails/ActionView/Rendering/view_context )。 これを使うとビューのメソッドをコントローラー等から使うことができて、paginate(user)が呼べます。
ここで使う変数userにはwithout_countは付けていませんので、「レコードの全件数を取得するクエリ」が発行されます
paginateメソッドで作られたページネーション要素ですが、formatがjsonだと(html以外だと)、リンクの末尾が.jsonとなってしまいます。ですのでgsubで消します。
出来上がったhtml文字列をjsonで包んで返します。

受け取ったjavascript側では、それを適当な要素にくっつければ完了です。

fetch(path).then(response => response.text()).then(html => {
  paginator.insertAdjacentHTML('afterbegin', html);
})

非同期でページネーション要素を出せました。
(↓の例ではsleepを入れています)

いいですね、非同期。
この記事が誰かの役に立てればです。