Rails: 自分自身をHotwireでリフレッシュするコンポーネントを作る(翻訳)
原注
これは、Ruby on Railsを長年愛しているJesper Christiansenをゲストに迎えたコラボ記事です。
今年の初め頃、Hotwireアプリを大きく改善できると私が考えているパターンについて以下でツイートしました。
This is really the GOAT pattern for hotwire apps
- Make a component for view logic
- When backend does something, use turbo_stream to re-render the component with new data
- Same thing when broadcasting from a jobPartials just suck with turbo_stream IMO pic.twitter.com/onGJ5zqh3t
— matt swanson 😈 (@_swanson) February 28, 2025
バックグラウンドで何らかのコードを実行するささやかなUIを作る必要が生じたときは、turbo_streamsを利用する方法が使えます。実行開始時、実行中、そして実行終了時にそれぞれフロントエンドを「リフレッシュ」するということです。
しかしturbo_streamsの問題は、そのままではパーシャルを使う必要があるということです。正直に申し上げると、これにはうんざりしています。はっと気づくと、dom_idをファイル内のあちこちで照合して回ったり、localsとしてデータに受け渡していて、嫌になります。大規模なコードベースの中から欲しいパーシャルを見つけ出すのも一苦労ですし、パーシャルの使い方がはっきりしていないせいでパーシャルをうっかり変更して他の部分がぶっ壊れるはめになったりします。
やがて経験を積むうちに以下のパターンを編み出したのですが、それは他では見たこともないようなものでした。
- ビューのコンポーネントを作成する(関連するロジックやヘルパーはコンポーネントに配置する)
- バックエンドで何か処理を行うときは、パーシャルとlocalsを使うのではなく、
turbo_stream.replaceのrenderableオプションにコンポーネントオブジェクトそのものを渡す - ジョブからブロードキャストするときも同じパターンを使う
dom_idsとActionCableブロードキャストチャネルなどの実装の詳細はコンポーネントにカプセル化する
それでは詳しく見ていきましょう!
🔗 コンポーネントをHotwireで自動更新する
たとえば、ユーザーカードのコンポーネントがあるとします。このコンポーネントは、ユーザーの基本情報や、紹介メールがそのユーザーに送信済みかどうかの情報をカード形式で表示します。
このカードにボタンを追加して、クリックしたら紹介メールがユーザーに送信されるようにしたいとします。さらに、メールの送信が完了するまでは「送信中」ステートをコンポーネントに表示したいとします。
表示されているユーザーカードの情報を常に最新に保つために、Hotwireのリフレッシュ機能を使うことにします。
🔗 いろんなidが絡まってつらい
バックグラウンドジョブ(もしくはモデル)では、以下のような感じでリフレッシュ操作を行うのが普通です。
Turbo::StreamsChannel.broadcast_replace_to(
"my-unique-identifier",
target: id,
partial: "user/card"
locals: { user: @user }
)
これだけなら何も困りませんよね!
しかしこの"my-unique-identifier"という文字列は何を指しているのかというと、もちろん、置き換える必要のある要素のidです。
つまりuser_cardパーシャルの中には以下のようにid: "my-unique-identifier"が存在することになります。
<% tag.div id: "my-unique-identifier" do %>
...
<% end %>
もちろん、実際にはこんな手書きのidを書かなくても、ActionView::RecordIdentifierのdom_idメソッドを使えます。つまりdom_id(@user, :user_card)のように書きます。
先ほどのuser_cardパーシャル内のmy-unique-identifier-stringのようなidをハードコードしていた部分をActionView::RecordIdentifier.dom_id(@user, :user_card)で書き換えれば一丁上がりです。
これはこれで悪くないのですが、理想にはほど遠い状態です。既に見てきたように、コンポーネントのid部分が更新されるたびに、コードベースをくまなく調べて、このマジック文字列を参照している部分を1つ残らず置き換えなければなりません。
せいぜいテストでしっかりカバーされていて見逃しがないことをお祈りするのが関の山でしょう。😅
アプリの規模が大きくなれば、こうやって置き換えなければならない箇所も大量に発生することになるでしょう。
これを解決するために、user_helper.rbあたりに以下のuser_card_idのような新しいメソッドを導入する方法が考えられます。
def user_card_id(user)
dom_id(user, :user_card)
end
これならuser_card_idヘルパーメソッドが必要になればどこでも使えるようになります。コードはたしかに改善されましたが、元のuser_cardパーシャルのコアロジックから切り離されている感じがつきまといます。
今後このパーシャルを削除することになったら、離れた場所にあるこのuser_card_idヘルパーも忘れずに削除しておかなければなりません。さもないと無用の歴史として降り積もってしまうことになります。やれやれ。
そういえばturbo_stream_from [@user, :user_card_refresh]の話がまだでしたが、これも必要に応じて離れたコード同士を同期する必要があります。さらに、何らかのステートに応じて条件付けされる可能性すらあります。
🔗 ViewComponentは救いの神
ViewComponentは(あるいはPhlexでも何でもお好きなものをどうぞ)、そうしたユースケースに最適であることがわかってきました。コンポーネントを構築することで、複雑さのレベルが相当高くなっても単一のRubyクラスに品よくパッケージ化できます。
つまり、コンポーネントはレンダリングだけ行うものと決めてかかる必要はなく、コンテンツを自力で更新する役割を果たしてもよいということです。
もしかすると私たちは責務をまぜこぜにして単一責任の原則を破ろうとしているのでしょうか?そうかもしれませんが、最近の私は「振る舞いの局所性」↓を重視するシステムを手掛ける方がずっと好きです。
参考: </> htmx ~ Locality of Behaviour (LoB)
それでは、シンプルなUI::UserCardコンポーネントを見てみましょう。
原文補足
私はコンポーネント名にUI::を付ける方法が大好きです。こうすれば、たとえばUI::UserCardとするだけでそのオブジェクトがViewComponenであることがひと目で明確にわかり、アプリケーション内のあらゆるコンポーネントのクラス名にいちいちUserCardComponentのように長ったらしいComponentサフィックスを追加せずに済むからです。皆さんにも強くおすすめします!
class UI::UserCard < ApplicationComponent
def initialize(user:)
@user = user
end
def id
dom_id(@user, :user_card)
end
def broadcast_channel
[@user, :user_card_refresh]
end
def broadcast_refresh!
Turbo::StreamsChannel.broadcast_replace_to(
broadcast_channel,
target: id,
renderable: self,
layout: false
)
end
end
すると、同じディレクトリに配置したコンポーネントのビュー(erb)ファイルでidメソッドやbroadcast_channelメソッドを使えるようになります。
<% tag.div id: id do %>
<%= helpers.turbo_stream_from broadcast_channel %>
<div class="text-lg font-bold"><%= @user.name %></div>
<div class="text-sm text-slate-500"><%= @user.email %></div>
<% end %>
これで、コンポーネント内からidを参照してbroadcast_channelからストリーミングが送信されます。
このようにして、指定のユーザーのUI::UserCardを再レンダリングするための更新処理が非常にシンプルになります。
ついに、コンポーネント自身の内部でbroadcast_refresh!メソッドが使えるようになりました!以下のコードは、バックグラウンドやモデルから呼び出すのはもちろん、他のユーザーにブロードキャストしたい更新処理を行うどんな場所からでも自由に呼び出せます。
UI::UserCard.new(user: @user).broadcast_refresh!
このコンポーネントは自分自身をリフレッシュする方法を知っているうえに、頼みの綱の「マジック」idはコンポーネント自身の真ん中にしっかりとカプセル化されています。
このようにして、ユーザーに新しい紹介メールを送信するときはコントローラ内で以下のようにコンポーネントを呼び出せます。
def create
@user = Current.account.users.find(params[:id])
@user.send_introduction_email_later!
user_card = UI::UserCard.new(user: @user, sending_email: true)
render turbo_stream: turbo_stream.replace(user_card.id, user_card)
end
これにより、バックグラウンドジョブがユーザーのsend_introduction_email_later!メソッドでキューに追加され、このアクションをトリガーしたボタンをクリックしたユーザーの UI::UserCard を置き換えるためのレスポンスをturbo_streamで送信します。
先ほどコンポーネントのリフレッシュの例で使ったのと同じidメソッドをここでも使っていることにご注目ください。つまり、今後このidメソッドを更新するときは1つのコンポーネント内で済むのです。
🔗 ストリームを開きっぱなしにする必要はない
ストリーミングを開きっぱなしにするのは理想的とは言えないでしょう。特に、ここで更新したいのはユーザーへのメール送信状態であり、それ以外は何も更新したくありません。
UI::UserCardコンポーネントに、そのユーザーにメールを既に送信したかどうかを表すsending_email?ステートを導入しましょう。このステートは、メールの送信処理中にのみチャネルからストリーミングするのにも使えます。
UI::UserCardコンポーネントのinitializeメソッドをsending_email?メソッドで更新しましょう。
class UI::UserCard < ApplicationComponent
def initialize(user:, sending_email: false)
@user = user
@sending_email = sending_email
end
def sending_email?
@sending_email
end
def introduction_email_sent?
@user.introduction_email_sent_at.present?
end
...
end
次は、sending_email?のビューファイルに、この新しいsending_emailを条件として追加します。
<% tag.div id: id do %>
<div class="text-lg font-bold"><%= @user.name %></div>
<div class="text-sm text-slate-500"><%= @user.email %></div>
<% if sending_email? %>
<%= helpers.turbo_stream_from broadcast_channel %>
<%= render UI::Spinner.new(size: :sm, message: "Sending introduction email") %>
<% elsif !introduction_email_sent? %>
<%= helpers.button_to "Send introduction email", user_emails_introduction_path(@user) %>
<% end %>
<% end %>
次に、紹介メールを送信するバックグラウンドジョブを実行します。
class SendUserIntroductionEmailJob < ApplicationJob
queue_as :default
def perform(user)
user.send_introduction_email_later!
UI::UserCard.new(user: user, sending_email: false).broadcast_refresh!
end
end
UI::UserCardコンポーネントで、sending_emailをtrueに設定したバージョンに置き換えます。これによって、broadcast_channelからの更新がサブスクライブされ、メール送信処理中の状態をコンポーネントに表示されるようになります。
この作業が完了したら、コンポーネントを sending_email: false に置き換えます。これにより、更新のリッスンとサブスクリプションが停止し、リソースの消費が抑えられます。
🔗 まとめ
ViewComponentなどのコンポーネントを使って、UIのパーツをビジネスロジックとともにコンポーネントに閉じ込めると、大きなメリットを得られます。
ロジックがコンポーネントにカプセル化されるので、何かが変更されたらUIをリフレッシュするなどの処理を明確に書けるようになります。
コンポーネント化すると、コンポーネントのidメソッドや、更新をプッシュするチャネル指定(broadcast_channel)の定義も1箇所にまとまるので、将来のリファクタリングもやりやすくなります。
関心の分離(separation of concerns)をコチコチに守るよりも、振る舞いを一箇所に凝縮させる方が、多くの場合コードベースの作業がぐっと楽になります。
概要
元サイトの許諾を得て翻訳・公開いたします。