Hotwireでリアルタイムなチャットを作る

## はじめに

12/23にRuby on Railsの生みの親:DHH氏が`Hotwire`という新しい`gem`について言及され、それが話題になっているようだったので試してみた記事です。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Also cross-posted the screencast from the Hotwire to YT. That fucking thing nearly killed me. Hours and hours of work to get this just like I wanted it. But it&#39;s a clean 10 minutes on the main tech, then another 3 on showing it in real life. ACTION! 😄 <a href="https://t.co/0MWpEPnSzx">https://t.co/0MWpEPnSzx</a></p>&mdash; DHH (@dhh) <a href="https://twitter.com/dhh/status/1341421648050860034?ref_src=twsrc%5Etfw">December 22, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


本記事ではチュートリアルとして進めつつ`Hotwire`のデモと同じアプリを作っていきます。

[Hotwire デモ](http://www.youtube.com/watch?v=eKY-QES1XQQ&t=1s)

また、本記事では`Hotwire`を試してみて気づいたことなども紹介しつつ、`Hotwire`の魅力を伝えられればと思います。

## 完成品

![hotwire-sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191521/41661b1e-6dcb-81cb-2c0a-e83024ef089d.gif)

[ソースコードはこちらから](https://github.com/S-H-GAMELINKS/hotwire-sample)


## 環境

以下は、僕が実際に作ったときの環境です

OS: `Windows10(WSL2 + Ubuntu18.04)`
Ruby: `ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]`
Rails: `Rails 6.1.0`
Bundler: `Bundler version 2.1.4`

また`Hotwire`は`Websocket`を使っているので`Redis`も使用しています。


## チュートリアル
### rails new

何はともあれ、まずは`rails new`からはじめましょう。

```bash
rails new hotwire-sample --skip-webpack-install
```

`--skip-webpack-install`を追加しているのは、`Hotwire`で`Webpacker`を使わないからですね。
それと、`Webpacker`がインストールされていると`turbo_frame_tag`周りの挙動がおかしくなるので、それを回避するためでもあります(原因とかは調べてますが、まだわからない……)

ちなみに、僕は動画の通り進めていたつもりが`turbo_frame_tag`周りの挙動でかなり頭を悩ますことになりました……。

`rails new`が終了後、`cd hotwire-sample`を実行してディレクトリを移動します。

```bash
cd hotwire-sample
```

### Webpacker関連のファイルの削除

`--skip-webpack-install`をつけて`rails new`したんですが、`Webpacker`周りのファイル(`Gemfile`の記述とか)が残っているのでそれを削除していきます。

まずは`app/javascripts`ディレクトリを削除します。削除方法は`rm`コマンドでも、マウスで削除でも構いません。

次に、`Gemfile`に残っている以下の`Webpacker`の部分を削除します。

```ruby
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker	
gem 'webpacker', '~> 5.0'
```

削除後、`bundle install`を実行するのを忘れないように気をつけてください。

```bash
bundle install
```

最後に、`Webpacker`でビルドしたJavaScriptを読み込んでいるタグを削除します。
場所は`app/views/layouts/application.html.erb`内の`<head>`タグ内にありますね。

```erb
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
```

ここまでの段階で`Webpacker`周りの設定はきれいに削除できました！


### Hotwireのインストール

ようやく`Hotwire`のインストールです。

`Gemfile`に以下のコードを追加します。

```ruby
gem 'hotwire-rails'
```

次に、`bundle install`を実行します。

```bash
bundle install
```

あとは、`Hotwire`をインストールしてくれる`hotwire:install`を実行すればOKです！

```bash
rails hotwire:install
```

これで`Hotwire`のインストールは完了です！

### roomとmessageを作成

デモでは`room`と`message`を使ってチャットルームを作っていましたので、同じようにそれぞれ作っていきます。

```bash
rails g scaffold room name:string
rails g model message room:references content:text
```

`rails g scaffold room name:string`で`room`を作成し、`rails g model message room:references content:text`で`message`を作成してます。

次に`rails db:migrate`でマイグレーションを実行します。

```bash
rails db:migrate
```

マイグレーション後、ルーティングを修正します。

```diff:config/routes.rb
Rails.application.routes.draw do
-  resources :rooms
+  resources :rooms
+    resources :messages
+  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
```

次に、`app/models/room.rb`に`message`モデルへのリレーションを追加します。

```diff:app/modesl/room.rb
class Room < ApplicationRecord
+    has_many :messages
end
```

まだ`message`用のコントローラーを作っていないので、それも作ります。
`app/controllers/messages_controller.rb`を以下のように作成します。

```ruby
class MessagesController < ApplicationController
  before_action :set_room, only: %i[ new create ]

  def new
    @message = @room.messages.new
  end

  def create
    @message = @room.messages.create!(message_params)

    redirect_to @room
  end

  private
    def set_room
      @room = Room.find(params[:room_id])
    end

    def message_params
      params.require(:message).permit(:content)
    end
end
```

コントローラーはこれで出来たので、次は`message`用のパーシャルとビューを作成します！
`app/views/messages/new.html.erb`と`app/views/messages/_message.html.erb`をそれぞれ以下のように作ります！

```erb:app/views/messages/new.html.erb
<h1>New Message</h1>

<%= form_with(model: [ @message.room, @message ]) do |form| %>
    <div class="field">
        <%= form.text_field :content %>
        <%= form.submit "Send" %>
    </div>
<% end %>

<%= link_to 'Back', @message.room %>
```

```erb:app/views/messages/_message.html.erb
<p id="<%= dom_id message %>">
  <%= message.created_at.to_s(:short) %>: <%= message.content %>
</p> 
```

最後に、`app/views/rooms/show.html.erb`に以下のように`message`を表示する箇所と`message`を新しく作る画面へのリンクを追加します。

```diff:app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<p>
  <strong>Name:</strong>
  <%= @room.name %>
</p>

<%= link_to 'Edit', edit_room_path(@room) %> |
<%= link_to 'Back', rooms_path %>

+ <div id="messages">
+   <%= render @room.messages %>
+ </div>
+ 
+ <%= link_to "New Message", new_room_message_path(@room) %>
```

とりあえずここまででチャットができるようになりました！

### TurboでSPA化

ここからは`Hotwire`を導入したことで使えるようになる`Turbo`を使っていきます！

ですが、その前にどの部分で`Turbo`が使われているか分かりやすくなるようにCSSを少しいじります。

`app/assets/stylesheets/application.css`に以下のようにCSSを追加します。

```diff:app/assets/stylesheets/application.css
/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */

+ turbo-frame {
+    display: block;
+    border: 1px solid blue;
+ }
```

以下の部分を追加していますね。

```css
 turbo-frame {
    display: block;
    border: 1px solid blue;
 }
```

これで`Turbo`を使っている箇所は青枠で表示されるので分かりやすくなります！

それでは`Turbo`を使っていきます！
まずは`app/views/rooms/show.html.erb`を以下のように変更します。

```diff:app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

- <p>
-   <strong>Name:</strong>
-   <%= @room.name %>
- </p>
- 
- <%= link_to 'Edit', edit_room_path(@room) %> |
- <%= link_to 'Back', rooms_path %>
+ <%= turbo_frame_tag "room" do %>
+   <p>
+     <strong>Name:</strong>
+     <%= @room.name %>
+   </p>
+ 
+   <%= link_to 'Edit', edit_room_path(@room) %> |
+   <%= link_to 'Back', rooms_path %>
+ <% end %>

<div id="messages">
  <%= render @room.messages %>
</div>

<%= link_to "New Message", new_room_message_path(@room) %>
```

`<%= turbo_frame_tag "room" do %>`から`<% end %>`までが`Turbo`が適用される範囲です！
間違ってこの範囲に`message`関連の表示とかを含めると意図しない挙動になるので注意しましょう。

同じように`app/views/rooms/edit.html.erb`にも`turbo_frame_tag`を追加します。

```diff:
<h1>Editing Room</h1>

+ <%= turbo_frame_tag "room" do %>
+     <%= render 'form', room: @room %>
+ <% end %>

<%= link_to 'Show', @room %> |
<%= link_to 'Back', rooms_path %>
```

ここまでの段階で、`app/views/rooms/show.html.erb`で`Edit`をクリックするとその部分だけが編集用のフォームに切り替わります。

このように`turbo_frame_tag`で括っている範囲内の表示をリンクを経由して差し込むことができるようです。
また引数に渡している文字列はその`turbo-frame`の`id`などになっているようです。

ただし、一点注意が必要で、`app/views/rooms/show.html.erb`で`Back`をクリックすると一覧へともどることが出来ません。
これは`turbo_frame_tag`で括られている箇所が`app/views/rooms/index.html.erb`内にないためだと思われます。

そこで、`"data-turbo-frame": "_top"`を`link_to`に追加します。これは`Turbo`を使った画面遷移を超えてリンク先に移動できるようにするためのようです。

```diff:app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>

  <%= link_to 'Edit', edit_room_path(@room) %> |
-  <%= link_to 'Back', rooms_path %>
+  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<% end %>

<div id="messages">
  <%= render @room.messages %>
</div>

<%= link_to "New Message", new_room_message_path(@room) %>
```

これで一覧へと戻ることができるようになりました！

最後に、`Turbo`を使って`message`を作成するフォームを`app/views/rooms/show.html.erb`に埋め込んでみます。

まずは`app/views/messages/new.html.erb`で埋め込みたいフォームの部分を`turbo_frame_tag`で括ります。
先ほどまでと違うのは`, target: "_top"`を追加しています。

```diff:app/views/messages/new.html.erb
<h1>New Message</h1>

- <%= form_with(model: [ @message.room, @message ]) do |form| %>
-     <div class="field">
-         <%= form.text_field :content %>
-         <%= form.submit "Send" %>
-     </div>
- <% end %>
+ <%= turbo_frame_tag "new_message", target: "_top" do %>
+     <%= form_with(model: [ @message.room, @message ]) do |form| %>
+         <div class="field">
+             <%= form.text_field :content %>
+             <%= form.submit "Send" %>
+         </div>
+     <% end %>
+ <% end %>

<%= link_to 'Back', @message.room %>
```

なぜ`, target: "_top"`を追加しているのかはよくわかっていないんですが、先ほどのリンクの件のようにスコープの外からでも呼び出せる的なことなんじゃないかと……。

あとは`app/views/rooms/show.html.erb`のリンク部分を置き換えます。

```diff:app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>

  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

  <div id="messages">
    <%= render @room.messages %>
  </div>
<% end %>

- <%= link_to "New Message", new_room_message_path(@room) %>
+ <%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
```

これで、`app/views/rooms/show.html.erb`を表示すると`message`のフォームが埋め込まれています！

### リアルタイム更新用の準備

今のままだと`Turbo`経由でビューのHTMLがすべて受け取ったりして`message`を追加することになり面倒です。
そこで、追加された`message`の部分だけを差分で受け取ることができるように修正していきます。
また追加でその他の修正箇所も直していきます。

まずは`app/controllers/messages_controller.rb`の`create`メソッドを以下のように修正します。

```diff:app/controllers/messages_controller.rb
  def create
    @message = @room.messages.create!(message_params)
    
-    redirect_to @room
+    respond_to do |format|
+      format.turbo_stream
+      format.html { redirect_to @room }
+    end
  end
```

新しく`format.turbo_stream`を使えるようにしています。

次に`app/views/messages/create.turbo_stream.erb`を以下のように作成します。

```erb:app/views/messages/create.turbo_stream.erb
<%= turbo_stream.append "messages", @message %> 
```

これは対になる`erb`ファイルを経由して`id="messages"`のところに差分のHTMLだけを差し込むための修正です。
新しく`message`が作成されると`format.turbo_stream`を経由して`app/views/messages/create.turbo_stream.erb`にデータが渡され、最後に`message`を表示しているところに差分が差し込まれるという流れです。

次に、新しく`message`を作成するときに入力した文字などがリセットされていない問題を解消します。
その問題を解決するためにデモでは`Stimulus`というJavaScriptのフレームワークを使っています。

まず`app/assets/javascripts/controllers/reset_form_controller.js`を以下のように作成します。

```js:app/assets/javascripts/controllers/reset_form_controller.js 
import { Controller } from "stimulus"

export default class extends Controller {
    reset() {
        this.element.reset()
    }
}
```

これは`rest()`という入力された値をリセットするメソッドを作っています。

次に、作成した`reset()`をフォームで呼び出せるようにします。

`app/views/messages/new.html.erb`を以下のように修正します。

```diff:app/views/messages/new.html.erb
<h1>New Message</h1>

<%= turbo_frame_tag "new_message", target: "_top" do %>
-    <%= form_with(model: [ @message.room, @message ]) do |form| %>
+    <%= form_with(model: [ @message.room, @message ], data: { controller: "reset_form", action: "turbo:submit-end->reset_form#reset" }) do |form| %>
        <div class="field">
            <%= form.text_field :content %>
            <%= form.submit "Send" %>
        </div>
    <% end %>
<% end %>

<%= link_to 'Back', @message.room %>
```

`Stimulus`では`data`に`{ controller: "reset_form", action: "turbo:submit-end->reset_form#reset" }`のように作成したファイル名と呼び出す際のイベントと呼び出されるメソッドを指定することができます。

これで`Send`をクリックすると入力内容がリセットされます！

### ActionCable(websocket)でリアルタイム更新

最後に`ActionCable(websocket)`でリアルタイム更新ができるように修正していきます！

まずは、`room`毎に`ActionCable(websocket)`への接続を追加します。

`app/views/rooms/show.html.erb`を以下のように修正していきます。

```diff:app/views/rooms/show.html.erb
<p id="notice"><%= notice %></p>

+ <%= turbo_stream_from @room %>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>

  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

  <div id="messages">
    <%= render @room.messages %>
  </div>
<% end %>

<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
```

`<%= turbo_stream_from @room %>`を追加しているだけで、簡単ですね。

次に、`message`が作成されたときに`ActionCable(Websocket)`へと配送する処理を追加します。
これも非常に簡単で`app/models/message.rb`を以下のように修正するだけです。

```diff:app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room
+  after_create_commit -> { broadcast_append_to room }
end
```

これで`ActionCable(Websocket)`経由で新しい`message`が配送されます。

余談ですが`after_create_commit`は新しく`message`が作成されたイベントをフックしています。このほかにも`after_destroy_commit`や`after_update_commit`のように削除されたり、更新されたときのイベントをフックすることもできます。
なので、削除などもリアルタイムに処理することができます。

ここまでの修正で`app/views/messages/create.turbo_stream.erb`での処理は不要となっているので以下のようにコメントだけに修正します。

```diff:app/views/messages/create.turbo_stream.erb 
- <%= turbo_stream.append "messages", @message %> 
+ <% # Return handled by cable %>
```

現状では作成された`message`の配送はしていますが、削除などは対応していません(`after_destroy_commit`を追加していないので)

そこでそれらすべてをまとめて追加できる`broadcasts_to`を`app/models/message.rb`に追加します。

```diff:app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room
-  after_create_commit -> { broadcast_append_to room }
+  broadcasts_to :room
end
```

基本的に個別のイベントをフックしたいとかでもない限りは`broadcasts_to`を使うとよさそうです。

ここまでの段階でリアルタイムに`message`をやり取りすることができます！(ただし、`Redis`は必要!)

最後に、`room`の情報もリアルタイムに共有できるようにします！
まずは`app/models/room.rb`に配送周りの処理を追加します。

```diff:app/models/room.rb
class Room < ApplicationRecord
    has_many :messages
+    broadcasts
end
```

追加するのは`broadcasts`だけです。

あとは`app/views/rooms/_room.html.erb`を以下のように作成し、それを`app/views/rooms/show.html.erb `でパーシャルとして呼び出すだけです。

```diff:app/views/rooms/_room.html.erb
<p id="<%= dom_id room %>">
    <strong>Name:</strong>
    <%= room.name %>
</p> 
```

```diff:app/views/rooms/show.html.erb 
<p id="notice"><%= notice %></p>

<%= turbo_stream_from @room %>

<%= turbo_frame_tag "room" do %>
-  <p>
-    <strong>Name:</strong>
-    <%= @room.name %>
-  </p>
+  <%= render @room %>

  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>

  <div id="messages">
    <%= render @room.messages %>
  </div>
<% end %>

<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>
```

これで`room`の`name`を編集した内容がリアルタイムで共有されます！

## 所感

かなり簡単かつ手早くSPA+Websocketを使ったアプリが作れるのでこれは非常に便利！
少人数のチームでサクッと作るのにかなり向いてそうですね。

あとは、JavaScriptに強い人がいないチームとかでは重宝されそうですね！

## 参考

ref: [速報: Basecampがリリースした「Hotwire」の概要](https://techracho.bpsinc.jp/hachi8833/2020_12_24/102368)
ref: [hotwired/hotwire-rails-demo-chat](https://github.com/hotwired/hotwire-rails-demo-chat)
ref: [hotwired/stimulus-rails](https://github.com/hotwired/stimulus-rails)
ref: [hotwired/turbo-rails](https://github.com/hotwired/turbo-rails)
ref: [hotwired/hotwire-rails](https://github.com/hotwired/hotwire-rails)
ref: [Hotwire](https://hotwire.dev/)
ref: [Hotwire デモ](http://www.youtube.com/watch?v=eKY-QES1XQQ&t=1s)
ref: [Rails環境でJS , CSSをwebpackで完全に管理する](https://qiita.com/geek_shanshan/items/8f348734d95d9ece9576)
ref: [rails new with --skip-webpack-install still includes the webpacker gem. #35484](https://github.com/rails/rails/issues/35484)
ref: [How to use Hotwire in Rails](https://gorails.com/episodes/hotwire-rails)