Hotwireで無限ローディング

# はじめに

無限ローディング（無限スクロール）は、Webサイトやアプリケーションで使用される機能です。ユーザーがページの最下部までスクロールすると、自動的に新しいコンテンツが読み込まれる仕組みです。この機能により、ユーザーは「次へ」ボタンをクリックすることなく、継続的にコンテンツを閲覧できます。

これは、[Hotwire](https://hotwired.dev/)の[Turbo](https://turbo.hotwired.dev/)の機能を使えば簡単に実装できます。

例えば、以下のサイトで提案されている方法です。これは、Turbo Framesの遅延読み込みのみを使った方法です。

https://zenn.dev/shita1112/books/cat-hotwire-turbo/viewer/tutorial-3#%E7%84%A1%E9%99%90%E3%82%B9%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%AB%E3%81%AE%E5%AE%9F%E8%A3%85

この方法とは異なる実装方法を、同僚の[@kei-p](https://qiita.com/kei-p) さんに教えてもらいました。これは、Turbo Framesの遅延読み込みとTurbo Streamsの両方を使った方法です。この実装方法について書かれた記事が見当たらなかったので、ここでまとめたいと思います。

## 環境

- Ruby 3.3.6
- Ruby on Rails 8.0.0
- turbo-rails 2.0.11

# Turbo Framesの遅延読み込みだけを使う方法

 記事一覧を無限ローディングする例を使って、Turbo Framesの遅延読み込みだけで実装する方法について説明します。

![Dec-01-2024 21-00-07.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3472392/376f3122-6211-eaaa-314a-9536bf0e35ed.gif)

この画面は、以下のcontroller, viewで実現しています。

```rb:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    page_per = 10
    @current_page = params[:page].to_i
    @articles = Article.where('id >= ?', @current_page * page_per)
                       .order(:id)
                       .limit(page_per)
    @next_page = @current_page + 1
  end
end
```

```erb:app/views/articles/index.html.erb
<h1>記事一覧</h1>

<ul>
  <%= turbo_frame_tag "articles-page-#{@current_page}" do %>
    <% @articles.each do |article| %>
      <li>
        <%= link_to article.title, article_path(article) %>
      </li>
    <% end %>

    <!-- まだ表示されていない記事を遅延読み込み -->
    <%= turbo_frame_tag "articles-page-#{@next_page}", loading: :lazy, src: articles_path(page: @next_page) %>
  <% end %>
</ul>
```

11行目の`turbo_frame_tag`で、まだ表示されていない記事を遅延読み込みしています。この行により以下のようなHTMLが生成されます。このHTMLがブラウザ上で見える位置にある時、`src`属性で指定してURLを読み込みます。`loading`属性の値を`"lazy"`にすると遅延読み込みされます。

```html
<turbo_frame id="articles-page-1" loading="lazy" src="/articles?page=1">
</turbo_frame>
```

この`<turbo_frame>`タグが、この遅延読み込みのレスポンスにあるHTMLと置き換わります。レスポンスは以下のようになるので、`id`が`articles-page-1`のHTMLに置き換わります。
```html
<head>
</head>
<body>
    <h1>記事一覧</h1>
    <ul>
      <!-- ここから -->
      <turbo-frame id="articles-page-1">
        <li>
          <a href="/articles/10">記事タイトル 8</a>
        </li>
        <li>
          <a href="/articles/11">記事タイトル 9</a>
        </li>
        <li>
          <a href="/articles/12">記事タイトル 10</a>
        </li>
        <!-- 省略 -->
        <turbo-frame loading="lazy" id="articles-page-2" src="/articles?page=2"></turbo-frame>
      </turbo-frame>
      <!-- ここまで -->
    </ul>
  </body>
</html>
```

つまり、HTMLは以下のようになります。

```html
<!-- 省略 -->
<ul>
  <li>
    <a href="/articles/10">記事タイトル 0</a>
  </li>
  <li>
    <a href="/articles/11">記事タイトル 1</a>
  </li>
  <li>
    <a href="/articles/12">記事タイトル 2</a>
  </li>
  <!-- 省略 -->
  <turbo-frame id="articles-page-1">
    <li>
      <a href="/articles/10">記事タイトル 8</a>
    </li>
    <li>
      <a href="/articles/11">記事タイトル 9</a>
    </li>
    <li>
      <a href="/articles/12">記事タイトル 10</a>
    </li>
    <!-- 省略 -->
    <turbo-frame loading="lazy" id="articles-page-2" src="/articles?page=2"></turbo-frame>
  </turbo-frame>
</ul>
```

このように`<ul>`内のネストが一段増えます。意図的にネストを増やしたわけではありません。無限ローディングを実現したいがために、仕方なくネストが一段増えてしまっています。`<ul>`の子要素は`<li>`であってほしいです。

# Turbo Framesの遅延読み込みとTurbo Streamsを使う方法

Turbo Framesの遅延読み込みと、Turbo Streamsを使う方法であれば、`<ul>`内のネストを余計に増やすことなく、無限ローディングを実現できます。

この実装では、記事へのリンクを表示する部分と、遅延読み込みを行うところで分けています。

```erb:app/views/articles/index.html.erb
<h1>記事一覧</h1>

<!-- 記事へのリンクを表示 -->
<ul id="articles" >
  <%= render 'articles', articles: @articles %>
</ul>
<!-- 記事へのリンクを表示 -->

<!-- 遅延読み込み -->
<%= render 'loading', page: @next_page %>
<!-- 遅延読み込み -->
```

```erb:app/views/articles/_articles.html.erb
<% articles.each do |article| %>
  <li>
    <%= link_to article.title, article_path(article) %>
  </li>
<% end %>
```

遅延読み込みを行う部分は前項とほとんど同じですが、`src`属性で渡しているURLのformatを`turbo_stream`に変更しているところだけ異なります。

```erb:app/views/articles/_loading.html.erb
<%= turbo_frame_tag :articles_loading, loading: :lazy, src: articles_path(page:, format: :turbo_stream) do %>
  読み込み中...
<% end %>
```

`turbo_stream`フォーマットで`ArticlesController#index`アクションが実行されるので、以下のファイルがレンダリングされます。2行目で`id`が`articles`の要素、つまり`<ul>`に記事へのリンクが追加されます。そして、3行目で遅延読み込みを行う要素を置き換えています。

```erb:app/views/articles/index.turbo_stream.erb
<% if @articles.present? %>
  <%= turbo_stream.append :articles, partial: 'articles', locals: { articles: @articles} %>
  <%= turbo_stream.replace :articles_loading, partial: 'loading', locals: { page: @next_page} %>
<% else %>
  <%= turbo_stream.replace :articles_loading %>
<% end %>
```

この方法で無限ローディングした場合、記事一覧は以下のようなHTMLになります。このように`<ul>`内のネストを余計に増やすことなく、無限ローディングを実現できます。

```html
<ul id="articles">
  <li>
    <a href="/articles/2">記事タイトル 0</a>
  </li>
  <li>
    <a href="/articles/3">記事タイトル 1</a>
  </li>
  <li>
    <a href="/articles/4">記事タイトル 2</a>
  </li>
  
  <!-- 省略 -->
  
  <li>
    <a href="/articles/27">記事タイトル 100</a>
  </li>
</ul>
<turbo-frame loading="lazy" id="articles_loading" src="/articles.turbo_stream?page=2">
  読み込み中...
</turbo-frame>
```

# おわりに

Hotwireで無限ローディングを実装する２つの方法をまとめました。

1. Turbo Framesの遅延読み込みだけを使う方法
2. Turbo Framesの遅延読み込みとTurbo Streamsを使う方法

１つ目の方法は、Turbo Framesの遅延読み込みについて知っていれば実装できるので、学習コストが少ないです。一方、２つ目の方法は、Turbo Streamsについての知識も必要ですが、HTMLに余計なネストを増やさないので、HTMLの複雑度が小さくなります。

他の実装方法があれば知りたいので、よければ、コメントで教えてほしいです🙏