Rails 7のHotwireがどんなことをしてくれるものかを知りたいです

# はじめに

先月12月15日に Ruby on Rails 7.0 がリリースされたということで、自分でもその機能を試してみます。

リリースを伝えるブログ記事の日本語訳はこちらを読みました。

https://www.publickey1.jp/blog/21/rails_70nodejs.html

ここでは Hotwire について試してみます。

Hotwireは、[こちらの記事](https://world.hey.com/dhh/rails-7-will-have-three-great-answers-to-javascript-in-2021-8d68191b) では「It's a complete alternative to heavy JavaScript client-side apps that speak JSON to a backend.」と説明されています。早速使ってみましょう。

# デモアプリケーション

[こちら](https://github.com/ktakase00/bustime_demo) にソースコードを置きました。

# プロジェクトの新規作成

プロジェクトは `rails new` コマンドで作成しますが、これを実行するためにはあらかじめ以下の gem がインストールされている必要があります。

```sh
bundle init
echo "gem 'rails', '~>7.0.1'" >> Gemfile
echo "gem 'bootsnap'" >> Gemfile
echo "gem 'importmap-rails'" >> Gemfile
echo "gem 'turbo-rails'" >> Gemfile
echo "gem 'stimulus-rails'" >> Gemfile
echo "gem 'sprockets-rails'" >> Gemfile
bundle config set --local path vendor/bundle
bundle install

bundle exec rails new ../bustime -d postgresql --skip-jbuilder
```

※Rails 7.0.1 で、Ruby 3.1 対応になりました。

# Turbo

## Turbo Drive

モデルとコントローラを作成してみます。

```sh
bundle exec rails generate scaffold BusLine name:text
bundle exec rails generate scaffold BusSchedule departure_hour:bigint departure_minute:bigint bus_line_id:bigint
bundle exec rails db:migrate
bundle exec rails server -b 0.0.0.0
```

Webブラウザでアクセスします。

```
http://localhost:3000/bus_lines
```

一覧画面が表示されました。続けて新規作成画面を表示し、1件登録して、再び一覧画面に戻ります。

![20220104_Hotwire_09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27959/1ed25b64-104e-efe6-f38c-b6c0a84f01fb.png)

ここで、Webブラウザの開発ツール、「ネットワーク」で確認すると、新規作成画面以降のアクセスが XHR になっています。なんと、POST メソッドのアクセスも XHR です。

![20220104_Hotwire_08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27959/6cd5ffd9-70eb-99b7-2c46-2f63381eb90a.png)

従来通りにプロジェクトを作成しただけなのに、シングルページアプリケーションができあがりました。

不思議ですね。

## Turbo Frame

シングルページアプリケーション(SPA)といえば、例えば一覧画面で、画面の表示直後はスピナー(ローディングインジケータ)がくるくる回って、データの取得が終わったら結果が表示される、というような動作になっていたりします。

![20220104_Hotwire_10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27959/7e6e45a4-bd32-a19a-07df-1ee5878f31bb.png)


これはつまり、フロントエンドで以下の処理を行っている、ということになります。

1. ローディングインジケータを表示する
2. データ取得APIに対してリクエストを送信する
3. 取得した JSON 文字列を分解し、テンプレートに流し込む
4. テンプレートから生成された HTML を DOM ツリーに差し込む(画面に表示する)
5. ローディングインジケータを非表示にする

使用するフロントエンドのフレームワークによって書き方はさまざまかと思いますが、必要な処理は同じです。

Rails 7.0 でやってみます。

```erb:app/views/bus_schedules/_schedule_table.html.erb
<%= turbo_frame_tag 'bus_schedules', src: bus_schedules.nil? ? list_bus_schedules_path : nil do %>
  <table class="bus-schedule-table">
    <thead>
      <tr>
        <th class="departure-hour">時</td>
        <th class="departure-minute">分</td>
        <th class="line">路線</td>
        <th class="operation">操作</td>
      </tr>
    </thead>
    <tbody>
      <% if bus_schedules.nil? %>
      <tr>
        <td colspan="4" class="loading"><%= image_tag 'loading.gif' %></td>
      </tr>
      <% else %>
        <% bus_schedules.each do |bus_schedule| %>
        <tr>
          <td class="departure-hour"><%= bus_schedule.departure_hour %></td>
          <td class="departure-minute"><%= bus_schedule.departure_minute %></td>
          <td class="line"><%= bus_schedule.bus_line.name %></td>
          <td class="operation"><%= link_to "Show this bus schedule", bus_schedule, data: { turbo_frame: '_top' } %></td>
        </tr>
        <% end %>
      <% end %>
    </tbody>
  </table>
<% end %>
```

```erb:app/views/bus_schedules/index.html.erb
<p style="color: green"><%= notice %></p>

<h1>Bus schedules</h1>

<%= render partial: 'schedule_table', locals: { bus_schedules: nil } %>

<%= link_to "New bus schedule", new_bus_schedule_path %>
```

```erb:app/views/list_bus_schedules/index.html.erb
<%= render partial: 'bus_schedules/schedule_table', locals: { bus_schedules: @bus_schedules } %>
```

```ruby:app/controllers/list_bus_schedules_controller.rb
class ListBusSchedulesController < ApplicationController
  def index
    sleep 2
    @bus_schedules = BusSchedule.all
  end
end
```

エラー処理などが一切ありませんが、期待通りの動作ができました。

あれ、一覧のデータ取得はどこで行っているのでしょうか？ローディングインジケータはどうやって切り替えたのでしょうか？むむ。

データ取得は、`turbo_frame_tag` において、`src` 属性にパスを指定することで行っています。`src` が指定されていると、ページの読み込み完了時に、自動的にそのパスにリクエストが送信されます。

`turbo_frame_tag` は、`<turbo-frame>` というタグを生成します。リクエストから応答が返却されると、その内容で、`<turbo-frame>` の中身が置き換わります。これにより、最初に表示していたローディングインジケータが消え、一覧に切り替わる、という動作になります。

ところで、ここに至るまで、このプロジェクトでは、まだ1行も JavaScript を書いていません。

不思議ですね。

## Turbo Stream

フロントエンドといえば、サーバプッシュの仕組みもよく利用されています。たとえばチャットの画面で、誰かが送信したメッセージが自動的に自分の Web ブラウザの画面に反映される、というような状況です。

WebSocket を用いる場合、フロントエンド側では以下の処理を行う必要があります。

1. コネクションを確立する。
2. サーバ側からのイベントを受け取る。
3. イベントからデータを取り出し、テンプレートに流し込む
4. テンプレートから生成された HTML を DOM ツリーに差し込む(画面に表示する)

チャットではありませんが、Webブラウザのタブを2つ開き、片方は一覧画面、もう片方は新規作成画面として、新規作成を行うと自動的に一覧画面に反映される、ということをやってみます。

![20220104_Hotwire_13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27959/2db1d1c0-a9ae-a00f-4002-c5c150be2942.png)

```ruby:app/models/bus_line.rb
class BusLine < ApplicationRecord
  has_many :bus_schedules
  after_create_commit -> { broadcast_append_to 'bus_lines', partial: 'bus_lines/list_item' }
  after_update_commit -> { broadcast_update_to 'bus_lines', partial: 'bus_lines/list_item' }
  after_destroy_commit -> { broadcast_remove_to 'bus_lines' }
end
```

```erb:app/views/bus_lines/_list_item.html.erb
<tr id="<%= dom_id bus_line %>">
  <td class="name"><%= bus_line.name %></td>
  <td class="operation"><%= link_to "Show this bus line", bus_line, data: { turbo_frame: '_top' } %></td>
</tr>
```

```erb:app/views/bus_lines/index.html.erb
<p style="color: green"><%= notice %></p>

<h1>Bus lines</h1>

<table class="bus-line-table">
  <thead>
    <tr>
      <th class="name">Name</th>
      <th class="operation">操作</th>
    </tr>
  </thead>
  <tbody id="bus_lines">
  </tbody>
</table>

<%= link_to "New bus line", new_bus_line_path %>

<%= turbo_stream_from 'bus_lines' %>

<% @bus_lines.each do |bus_line| %>
  <%= turbo_stream.append(:bus_lines, partial: "list_item", locals: { bus_line: bus_line }) %>
<% end %>
```

おや、どこでコネクションを確立したのでしょうか？どうやって画面上の一覧に新しい行を追加しているのでしょうか？

ERB のテンプレート内、`turbo_stream_from 'bus_lines'` が、コネクションを表しています。

モデルの `broadcast_append_to` が、コネクションに対してデータを追加するイベントを送信します。DBテーブルにレコードが追加された時に、この処理を実行しているので、新規作成の処理に合わせて一覧が更新される、というどうさになりました。

なんと、ここに至るまで、JavaScript を1行も書いていません。

不思議ですね。

# Stimulus

別にフロントエンドを毛嫌いしているわけでもなく、フロントエンドなしで何でもできるわけでもありません。

例えば、特に意味はありませんが、一覧画面において、いずれかの行がクリックされたら、その行の背景色が変わるようにしてみます。

![20220104_Hotwire_14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27959/316485b2-0895-05ed-73f4-3ac295007194.png)

必要な処理は以下のようになります。

1. 行がクリックされたことを検知できるようにする
2. 他の行の選択状態を解除する (選択状態であることを示すクラスを取り除く)
3. クリックされた行を選択状態にする (選択状態であることを示すクラスをセットする)

Rails 7.0 で書いてみます。

前述、Turbo Frame の部分で作成した `_schedule_table.html.erb` を編集し、かつ、JavaScript のファイルを追加します。

```erb:apps/views/bus_schedules/_schedule_table.html.erb
<%= turbo_frame_tag 'bus_schedules', src: bus_schedules.nil? ? list_bus_schedules_path : nil do %>
  <table class="bus-schedule-table" data-controller="schedule" data-schedule-selected-class="selected">
    : (中略)
        <% bus_schedules.each do |bus_schedule| %>
        <tr data-schedule-target="row" data-action="click->schedule#selectRow">
    : (中略)
        </tr>
        <% end %>
    : (中略)
  </table>
<% end %>
```

```javascript:app/javascript/controllers/schedule_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ 'row' ];
  static classes = [ 'selected' ];

  // コントローラがDOMと接続された時に実行されるハンドラー
  connect() {
    this.clearState();
  }

  // すべての行を未選択状態にする(selectedクラスを取り除く)
  clearState() {
    this.rowTargets.forEach((tableRow) => {
      tableRow.classList.remove(this.selectedClass);
    });
  }

  // クリックされた行を選択状態にする
  selectRow(event) {
    this.clearState();
    const tableRow = event.target.closest('tr');
    tableRow.classList.add(this.selectedClass);
  }
}
```

JavaScript は、おおよそ普通に見えますね。すべての行の選択状態を解除して、クリックされた行を選択状態にする、ということをしているだけです。

普通に見えますが、よくわからない部分があります。どうして `schedule_controller.js` で定義したクラスが、この `<table>` タグに対して動作するのでしょうか。もう一つ、クリックイベントのハンドラーを設定していないように見えますが、どうやってクリックを検知しているのでしょうか。

`<table>` タグに、以下の記述を追加しました。

```html
data-controller="schedule"
```

これにより、`schedule_controller.js` という名前のファイルが自動的に参照されるようになります。

さらに、`<tr>` タグに、以下の記述を追加しました。

```html
data-schedule-target="row" data-action="click->schedule#selectRow"
```

これにより(`targets` の宣言も必要ですが)、tr タグを JavaScript のクラス内、`this.rowTargets` で参照できるようになります。また、`data-action` で、クリック時のイベントハンドラーを設定しています。

フロントエンドのフレームワークは独自にテンプレート機能を持っていますが、Rails 7.0 では、ERB がそのままフロントエンドのテンプレートになっています。

# おわりに

Rails 7.0 で導入された、Hotwire と呼ばれる機能について、どんな機能なのか、どうやって使うのか、について確認しました。

既存のプロジェクトを Rails 7.0 にして何も考えずに Turbo を有効にするといろいろ動かなくなりそうですが、新しいプロジェクトであれば、手軽に WebSocket が使えたりするので、よいかもしれません。

# 参考

- [Rails 7.0正式リリース、Node.js不要のフロントエンド開発環境がデフォルトに](https://www.publickey1.jp/blog/21/rails_70nodejs.html)
- [Rails 7.0: Fulfilling a vision](https://rubyonrails.org/2021/12/15/Rails-7-fulfilling-a-vision)
- [Hotwire](https://hotwired.dev/)
- [Rails 7 will have three great answers to JavaScript in 2021+](https://world.hey.com/dhh/rails-7-will-have-three-great-answers-to-javascript-in-2021-8d68191b)
- [Rails 7.0.1 has been released](https://rubyonrails.org/2022/1/6/Rails-7-0-1-has-been-released)
- [Rails cashes with Ruby 3.1.0 release #43998](https://github.com/rails/rails/issues/43998)
- [Ruby on Rails](https://rubyonrails.org/)