Rails: モデルのdivタグをTurbo StreamとStimulusで動的に差し替える(翻訳)
RailsとHotwireの組合せは、動的な振る舞い(例: ユーザー操作へのレスポンスをHTMLとしてネットワーク経由で送信することでDOMを置き換える)について実に優れていますが、その処理内では半ダースものファイルを操作する必要があるため、、全体の印象として面倒に感じられます。
そもそもRails自身が常にそうなっています(1つ機能を足すためにルーティング/モデル/コントローラ/ビューなどの更新が必要)。Railsフレームワークの最も典型的なワークフローを学ぶには、通常の学習レシピと同様に、数か月ほど意識的に練習を重ねておく必要があるのが常ですが、Railsを長年使っていると、そのことをつい忘れてしまうことがあります。
そういうわけで、Turbo 8とStimulus 1.3とRails 7.1を使っている環境で、ユーザーが(select
で)モデルを切り替えたら、Railsがレンダリングした<div>
を常に差し替えられるようにする方法を、ここにレシピカードとして置いて再利用できるようにしておきます。
🔗 材料
- パーシャル:
置き換えたい要素内でレンダリングされるパーシャルを抽出して、指定のモデルがビューとTurbo Streamの両方で同じマークアップをレンダリングできるようにします。 -
ルーティング:
Turbo Streamで応答する使い捨てのコントローラアクションを指定するルーティングを追加します。 -
コントローラアクション:
モデルIDとDOM要素のIDを受け取り、Turbo Streamでレスポンスを返して要素のコンテンツを更新するアクションを定義します。 -
Turbo Streamビュー:
パーシャル呼び出しを行うアクションで使うTurbo Streamビューを作成します。 -
Stimulusコントローラ:
パス、ID、コンテナを指定すると、任意のモデルタイプを差し替え可能な汎用のStimulusコントローラを作成します。 -
ビュー:
Stimulusコントローラを、ビューのselectボックスと、置き換え対象の要素に接続します。
重要な材料は以上の6つでおしまいです。ご興味があれば、ステップ5はマジック成分が一番多いのでチェックしてみてください。
🔗 手を動かしてみよう
材料が揃ったところで、この機能をエンドツーエンドで実行するのに必要なステップを1つずつたどってみましょう。
🔗 1. Railsのパーシャルをセットアップする
まず、<div>
内でレンダリングしたいパーシャルを作成します。ここでは、Item
という汎用のモデルをユーザーが切り替え可能にしたいとします(対応するコントローラ名はいつも通りのItemsController
です)。
ここでは、項目の詳細情報をそのコントローラのビューでレンダリングするために、_detail.html.erb
というパーシャルを配置してみましょう。
<!-- app/views/items/_detail.html.erb -->
<div>
<%= item.title %>
<!-- 他にも項目があればここに書く... -->
</div>
🔗 2. ルーティングを追加する
次は、detail
アクションで必要なルーティングを追加します。
# config/routes.rb
resources :items do
get :detail, on: :collection
end
これでdetail_items_path
というパスヘルパーが定義され、"/items/detail"
にアクセス可能になります。
ここにon: :collection
オプションを追加した理由は、StimulusコントローラからURLを指定しやすくなるよう、"items/42/detail"
のような通常のmember
ルーティングではなくクエリパラメータでアクセスするためです。
🔗 3. コントローラのアクションを定義する
ルーティングを定義したら、Turbo Streamリクエストへの応答専用のシンプルなコントローラアクションを追加します。これは以下のような感じになります。
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
def detail
@dom_id = params[:dom_id]
@item = Item.find(params[:id])
end
end
最初はここでdom_id
パラメータを使っていることに戸惑うかもしれませんが、Turboの世界では一意のHTML IDが重要であることを知っておくのが重要です。詳しくはステップ5で後述します。
🔗 4. Turbo Streamビューを作成する
お馴染みの「ルーティング/コントローラ/ビュー」連携を完成させるために、detail
アクション用のビューを作成します。ただし拡張子はいつものhtml.erb
ではなくturbo_stream.erb
にします。
<!-- app/views/items/detail.turbo_stream.erb -->
<%= turbo_stream.update @dom_id do %>
<%= render partial: "detail", locals: { item: @item } %>
<% end %>
Turbo Streamのビューとオリジナルのビューは、項目を完全に同じ方法でレンダリングする必要があるので、detail.turbo_stream.erb
ビューでは上で作成した_detail.html.erb
パーシャルをレンダリングする形でこれに対応します。ブラウザが受け取るHTMLを調べてみると、このパーシャルを含むTurbo Streamタグだけが転送されていることがわかります。つまり、JSONをリクエストするのと同様のHTTPリクエストを行う形でSPAのJavaScriptとして実装する場合に比べて、多くの場合データの転送量の増加はこくわずかで済みます。
🔗 5. Stimulusコントローラを定義する
ユーザーがselectボックスで選んだ結果が反映されるようにするには、JavaScriptが必要です。このItem
モデルのみを対象としたStimulusコントローラを作成してもよいのですが、これを汎用的なものにしておけば、アプリの他の場所でも同じ機能を再利用できるようになります。では、その方針でやってみましょう。
面倒な方法で行うとすれば、ブラウザ組み込みのfetch
APIでURLを組み立てて、Accept
ヘッダーにtext/vnd.turbo-stream.html
を設定してから、DOM内にある要素のinnerHTML
を置き換えることになるでしょう。しかしこの方法は失敗しやすいという問題があります(実は私も本記事を書いている間に2回も失敗しました)。
これは自力でやるよりも、requestjs-rails gemを使う方法がおすすめです。
まず、Gemfileにある他のフロントエンド関連のgemに、以下を追加します。
gem "requestjs-rails"
最終的なStimulusコントローラは以下のようになります。今から深く潜りますので、皆さん深呼吸をお願いします。
// app/javascript/controllers/model_swap_controller.js
import { get } from '@rails/request.js'
import { Controller } from '@hotwired/stimulus'
export default class ModelSwapController extends Controller {
static targets = ['container']
static values = {
path: String,
originalId: String
}
swap (event) {
const modelId = event.currentTarget.value || // input操作から受け取った値
event.detail?.value || // カスタムイベントから受け取った値(hotwire_comboboxなど)
this.originalIdValue // inputの値がクリアされたら元の値にフォールバック
get(this.pathValue, {
query: {
id: modelId,
dom_id: this.containerTarget.id
},
responseKind: 'turbo-stream'
})
}
}
@rails/request.js
からインポートしたget
関数は、やって欲しい面倒な家事を一手に引き受けてくれます。このgemに乗り換えた直後にキレイに動いてくれたのを目にした瞬間、18年使い続けたRailsを今後も使い続けたくなる魔術的ワクワク感の手応えを得られました。
このStimulusコントローラには、2つの値と1つのターゲットも含まれています。
path
値- これは単なるURLであり、あえてパラメータなしの
detail_items_path
を設定します。 originalId
値- これはページが読み込まれたときに最初にレンダリングされる
Item
のIDです。この値をフォールバックとして利用可能にすることで、ユーザーがselect
で空オプションを選択したときにもスムーズに元のアイテムに戻せるようになります。 container
ターゲット- これは、これから差し替えられるパーシャルを含むDOM要素です。ここには一意の
id
属性が含まれていなければならない点にご注意ください(これはサーバーへのリクエストにdom_id
として含めることになります)。
以上の内容に理解できない部分がある場合は、とにかく自分で手を動かして実際に作り、動作中の値をデバッガで調べてみるのがおすすめです。
🔗 6. ビューでStimulusコントローラに接続する
最後のステップでは、最初に切り出しておいた_detail.html.erb
パーシャルの元のビューにアクセスします。
以下のビューを見た瞬間にもうお気づきかと思いますが、私は多数の属性をRubyの式として指定するときにcontent_tag
ヘルパーを使うのが大好きなのです。この方法なら、<div>
タグをそのままリテラルとして使う方法に比べて、<%=%>
を書く回数がずっと少なくて済みます。
<!-- app/views/items/show.html.erb -->
<%= content_tag :div, data: {
controller: "model-swap",
model_swap_path_value: detail_items_path,
model_swap_original_id_value: @item.id,
} do %>
<%= collection_select :item, :id, Item.all, :id, :title,
{include_blank: true},
{data: {action: "model-swap#swap"}} %>
<div id="<%= dom_id(@item, "detail") %>" data-model-swap-target="container">
<%= render partial: "detail", locals: { item: @item } %>
</div>
<% end %>
上のコードは、Stimulusを普段からよく使っている人ならすぐ馴染めると思いますが、Stimulusに馴染みのない人にとってはひたすら謎に見えるでしょう。後者の人々にわかりやすく説明するのは本記事の範疇を超えていますので、ChatGPTあたりにお尋ねください。
上のテンプレートには、ステップ5までに事前に決められない部分が1箇所だけあります。それはdiv
要素にラップされるid
属性です。そういうわけで、これについて説明しておきます。
このdiv
コンテナは、説明のためにdom_id(@item, "detail")
(これは"detail_item_42"
のような値になります)に設定して、これを一意の値になる例として示していましたが、実際には、どんなIDが最適なのかはページ内の周りの状況によって異なります。
たとえば、本記事を書くきっかけとなったUIでは、selectボックスの3つのオプション項目を表す可変配列のいずれかの要素をユーザーが置き換えられるようになっています。そのため、そこでのIDはoption_2_item_4
のようにそれらの配列の添字をベースにしています。ここで本当に重要なのは、すべてのIDが一意になっていることです。
🔗 以上でおしまいです
この10年間、このようなキビキビした動的な振る舞いが欲しければ、アプリのステートの複製コピーをトラッキングするために常駐するフロントエンド系JavaScriptフレームワークを使うしかないと思い続けていた多くの人にとって、TurboとStimulusでそうした機能を実現できるようになったのは実に嬉しいことです。
サーバーサイドレンダリングによるビューは、JavaScriptを介さずにページ全体をレンダリングできます。
そのおかげで、クライアント側でDOMのステートに対する変更を導入したときに、ビューが最初に定義した属性に対して安定的に実行できるようになりますし、アプリケーションの現在のステートに関する完全な「真実の情報源」が2か所(サーバー データベースとメモリ内のJavaScriptオブジェクト)に散らばらず、1か所(DOM)に集約されます。
とにかく、キレイに動くのは気分の良いものですし、何らかの理由でレゴブロックがうまく噛み合わないと腹立たしくなるものです。
Hotwireは、おそらくRailsが始まって以来、最もRailsらしさにこだわったフレームワーク拡張機能と言えるでしょう。Railsの細かな命名規約に合わせるために時間を溶かしている皆さん、それはあなただけではありません。Hotwireに慣れるには練習が必要なのです。
本記事のガイドに沿って作業を進めれば、今後も実際に再利用できる機能が完成するでしょう。本記事に誤りを見つけた方は、メールにてお知らせください。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。