Hotwire（Turbo）を試す その2: Turbo Streamsでページの一部の差替・追加

環境: Rails 6.1、Turbo 7.0.0-beta.5

[Hotwire（Turbo）を試す その1: 導入、作成・更新フォーム](https://qiita.com/kazutosato/items/10a5bc04443d6b7e5bf8) の続きです。

## Turbo Streamsとは

Turbo Streamsとは、サーバーからHTMLの一部を送信して、ページ中の一部を差替、追加、削除するものです。送信の方法には、普通のHTTPのレスポンスのほかにAction Cableが使えます。この「その2」のサンプルは、普通のHTTPのレスポンスを使うだけです。

ページ中に次のような`<turbo-frame>`要素があるとします。id属性でフレーム名を付けます。

```html:送信前のページ中のHTMLの一部
<turbo-frame id="message">
  <div>こんにちは</div>
</turbo-frame>
```

これが含まれたページに対して、次のような`<turbo-stream>`要素のHTML片を送信します。target属性で差替対象となるフレーム名 "message" を指定します。差替なのでaction属性は"replace"です。`<turbo-stream>`の内容は、`<template>`で囲む必要があります。

```html:送信するHTML片
<turbo-stream action="replace" target="message">
  <template>
    <div>こんばんは</div>
  </template>
</turbo-stream>
```

HTML片を送信すると、`<turbo-frame>`の内容が入れ替わります。

```html:送信後のページ中のHTMLの一部
<turbo-frame id="message">
  <div>こんばんは</div>
</turbo-frame>
```

なお、削除のときのHTML片は、action属性とtarget属性を指定しただけのものになります。

```html:送信するHTML片（削除時）
<turbo-stream action="remove" target="message">
</turbo-stream>
```

## 「いいね」ボタンの例

[その1](https://qiita.com/kazutosato/items/10a5bc04443d6b7e5bf8)で作ったサンプルに「いいね」ボタンを追加してTurbo Streamsを試します。記事の詳細ページの中に❤️ボタンを置き、クリックするとカウンタが1上がる、ということにします。

テーブルにカウンタを保存するカラムを追加します。

```ruby:db/migrate/20210419022554_add_likes_count_to_entries.rb
class AddLikesCountToEntries < ActiveRecord::Migration[6.1]
  def change
    add_column :entries, :likes_count, :integer
  end
end
```

いいね用のアクションを追加します。

```ruby:config/routes.rb
  resources :entries do
    patch :like, on: :member
  end
```

記事ページに埋め込む❤️ボタンとカウンタの数字です。`turbo_frame_tag "フレーム名"` メソッドは `<turbo-frame id="フレーム名">〜</turbo-frame>` になります。

```erb:app/views/entries/_likes.html.erb
<%= turbo_frame_tag 'entry-likes' do %>
  <div>
    <%= link_to '❤️', like_entry_path(@entry), method: :patch %>
    <span style="color:red"><%= @entry.likes_count %></span>
  </div>
<% end %>
```

これを記事ページに埋め込みます。

```erb:app/views/entries/show.html.erb
<%= render 'likes' %>
```

クリックで呼び出されるコントローラのいいね用アクションです。

```ruby:app/controllers/entries_controller.rb
  def like
    @entry.increment!(:likes_count)
    render turbo_stream: turbo_stream.replace('entry-likes', partial: 'likes')
  end
```

`render turbo_stream: turbo_stream.replace('名前', partial: 'テンプレート')`は、次のようなHTML片を送信し、`<turbo-frame id="フレーム名">`の内容を差し替えます。このとき、HTTPレスポンスのContent-typeは、`text/vnd.turbo-stream.html` になります。

```html
<turbo-stream action="replace" target="フレーム名">
  <template>
    partialのテンプレート
  </template>
</turbo-stream>
```

❤️をクリックすると数字が増えるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308595/cfa26c9e-c7ca-976f-763c-de087003fd83.png)
Turbo Streamsのテンプレートと送信には、もう1つやり方があります。次のように`<turbo-stream>`をテンプレートの中に書く方法です。`turbo_stream.replace "フレーム名"` は `<turbo-stream action="replace" target="フレーム名"><template>〜</template></turbo-stream>` になります。

```erb:app/views/entries/like.html.erb
<%= turbo_stream.replace 'entry-likes' do %>
  <%= render 'likes' %>
<% end %>
```

このテンプレートをContent-typeを指定して送信すると、同じ結果になります。

```ruby:app/controllers/entries_controller.rb
  def like
    @entry.increment!(:likes_count)
    render layout: false, content_type: 'text/vnd.turbo-stream.html'
  end
```

## 「もっと見る」ボタンの例

差替の次は、コンテンツの追加を試すために、記事一覧に「もっと見る」ボタンを実装します。まず、一覧のテンプレートを修正します。scaffoldのテンプレートはテーブルを使っているため、Turbo StreamsでHTML要素をうまく扱えません。divに変えます。

追加のターゲットになる記事の一覧は、`<turbo-frame>`で囲み、フレーム名を "entries" としています。

```erb:app/views/entries/index.html
<div>
  <%= turbo_frame_tag 'entries' do %>
    <% @entries.each do |entry| %>
      <%= render 'entry', entry: entry %>
    <% end %>
  <% end %>
</div>

<br>
<%= render 'more_button' %>
```

```erb:app/views/entries/_entry.html
<div class="entry">
  <%= link_to entry.title, entry %> |
  <%= entry.created_at.strftime('%m/%d %H:%M') %> |
  <%= link_to 'Edit', edit_entry_path(entry) %> |
  <%= link_to 'Destroy', entry, method: :delete, data: { confirm: 'Are you sure?' } %>
</div>
```

「もっと見る」ボタンのテンプレートです。10個ずつ記事を表示し、すべて表示されたらボタンを非表示とします。「もっと見る」も`<turbo-frame>`で囲み、フレーム名を "more-button" としています。

```erb:app/views/entries/_more_button.html
<% if @offset + 10 < @entries_count %>
  <%= turbo_frame_tag 'more-button' do %>
    <div>
      <%= link_to 'もっと見る', more_entries_path(offset: @offset + 10) %>
    </div>
  <% end %>
<% end %>
```

「もっと見る」用のルーティングです。

```ruby:config/routes.rb
  resources :entries do
    get :more, on: :collection
    patch :like, on: :member
  end
```

コントローラの記事一覧と「もっと見る」用のアクションです。記事は10個ずつ、offsetパラメータの位置から取得、作成日時の降順、とします。

moreアクションでは、`render turbo_stream: 〜` を使わない形にします。

```ruby:app/controllers/entries_controller.rb
  def index
    @entries_count = Entry.count
    @offset = params[:offset].to_i
    @entries = Entry.offset(@offset).order(created_at: :desc).limit(10)
  end

  def more
    index
    render layout: false, content_type: 'text/vnd.turbo-stream.html'
  end
```

「もっと見る」用のTurbo Streamsのテンプレートです。ここでは`<turbo-stream>`要素を2つ送信していいます。記事一覧の追加分と「もっと見る」の差替分です。追加するときは、replace の代わりに append を使います。

```erb:app/views/entries/more.html
<%= turbo_stream.append 'entries' do %>
  <% @entries.each do |entry| %>
    <%= render 'entry', entry: entry %>
  <% end %>
<% end %>

<%= turbo_stream.replace 'more-button' do %>
  <%= render 'more_button' %>
<% end %>
```

記事の数を増やして試すためにシードデータを用意します。

```ruby:db/seeds.rb
32.times do |idx|
  entry = Entry.create!(
    title: %w(foo bar baz).sample(3).join(' '),
    body: 'blah, blah, blah...',
    created_at: idx.hours.ago
  )
end
```

「もっと見る」をクリックすると記事が10個ずつ追加され、何度もクリックすると「もっと見る」が消えるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308595/edb2ce72-412f-89b5-007f-2a6a333648b0.png)

## turbo-frame の中のリンクの挙動

さて、この記事一覧の中のリンクをクリックすると、ページが切り替わらずに、記事一覧、つまり`<turbo-frame id="entries">〜</turbo-frame>`で囲んだ部分が消えてしまいます。

`<turbo-frame id="フレーム名">`の中にあるリンクは、レスポンスのHTMLにも`<turbo-frame id="フレーム名">`があることを期待し、元の`<turbo-frame>`を新しい`<turbo-frame>`で置き換える、というのがデフォルトの動作になっています。

この動作を変えて別のページに切り替わるようにするには、`<turbo-frame>`に属性`target="_top"`を加えます。

```erb:app/views/entries/index.html
  <%= turbo_frame_tag 'entries', target: '_top' do %>
```

## ここまでの感想

- Turboでこのサンプルを実装するのは、けっこう面倒でした。実際のアプリケーションに導入するには学習コストが気になります。まだドキュメントも解説サイトも揃っていない段階ではしょうがありませんが。
- Turbo Streamsによるページの内容更新は、すべてサーバーを経由するものなので、たとえば「追加ボタンを押したら入力欄が増える」みたいな動きには使えないと思う。
- 実際に導入するときは、Turbo+Stimulusの簡単な部分だけ使い、Vue.jsやjQueryと組み合わせるのがいいかもしれない。

続き: [Hotwire（Turbo）を試す その3: Turbo Streamsでのブロードキャスト](https://qiita.com/kazutosato/items/4b2d4177fc92335536e9)