💎

Hotwireにて動的に変化するHTMLフォームをTurbo Frames + Stimulusでシンプルに実現する

に公開

こんにちは、supermomongaです。最近、Rails + Hotwireの構成にて、selectタグの候補が動的に変わるフォームを構築したいといったケースに遭遇しました。

都道府県プルダウンを選ぶと市区町村プルダウンの選択肢一覧が変わる、みたいなのです。

これをHotwireで実現する方法はいくつかあると思いますが、今回はStimulus + Turbo Framesを使ってできるだけシンプルで汎用的に実装する方法を紹介したいと思います。

TL;DR

  • Turbo FrameをHTMLフォームそのもののFrameと、フォームをサブミットした際に更新したい結果部分のFrameの2つに分離し、「どのフレームを更新したいのか」をStimulusで明示的に制御する
  • GETリクエストだけで依存セレクトと結果表示を捌く設計に寄せると、ブラウザの戻る操作や直接URLアクセスとも整合が取りやすい
  • Turbo StreamsやGhost Formパターンを使用せず、既存のビューとアクションを再利用できる

1. 依存セレクトが絡むと何がつらいのか

都道府県→市区町村のような依存セレクトと、一覧の絞り込み結果を同居させる画面って、こんな挙動を同時に満たしたくなりますよね?

  • セレクトを変えた瞬間に従属フィールドだけ更新したい
  • 検索ボタンを押したときだけ結果リストを差し替えたい
  • 戻るボタンを押しても直前の条件が残っていてほしい

単純にselectタグのchangeイベントを購読してStimulusを通じたTurbo Framesの更新を行うことは可能ですが、その場合、Formが指定しているFrame(検索結果一覧など)しか更新されません。かといって、ページ全体を指定して更新するようにするのもTurbo Framesの良さが活かしきれていませんし、なにより「送信」ボタンを押していないのに結果が表示されてほしくない、などの問題があります。

2. 既存アプローチと参考リンク

依存セレクト問題はHotWire界隈でも度々議論されてきました。代表的な解決策としては次のようなものがあります。

どちらもサーバー主導でフォーム状態を管理しつつ部分更新する、という戦略を採っています。

3. 従来アプローチのつまずきポイント

ただ、現場で試してみると次のような課題にぶつかりました。

  • Ghost FormパターンはTurbo Framesで完結する点が良いが、専用のPOSTエンドポイントであるrefreshアクションを足す必要があり、かつフォーム自体も2つ記述する必要がある
  • Turbo Streamsはformat.turbo_streamテンプレートやstream配信コードが必要で、実現したいことに対して過剰な解決策である

つまり、「フォームはGETで素直に再訪問したい」「アクションをできるだけ増やさずシンプルに保ちたい」「既存のビューを極力いじりたくない」といった私のニーズとは完全に合致しませんでした。

4. Turbo Frames × Stimulusで組むシンプル設計

問題の本質は、フォーム送信時に更新したい対象のFrame(検索結果など)と、セレクトタグの値変更時に更新したい対象のFrame(フォームそのもの)が異なるという点です。

そこで今回はTurbo Frameを2枚に分け、Stimulusコントローラーで「今どのフレームを更新したいか」を明示的に切り替える構成を採用しました。GETベースで完結できるので、フォーム送信もセレクト変更も同じURLに落ち着きます。

4-1. フレームの配置とdata属性

フォームと結果を別フレームに分け、依存セレクトはフォーム側フレームで即時更新、検索結果はフォーム自体のdata-turbo-frameで差し替えます。

<%# app/views/locations/index.html.erb %>
<turbo-frame id="location-form"
             data-controller="dependent-form"
             data-dependent-form-target-frame-value="location-form">
  <%= form_with url: locations_path,
                method: :get,
                html: { data: { turbo_frame: 'search-results' } } do |form| %>
    <%= form.collection_select :prefecture_code, Prefecture.all, :code, :name,
          { prompt: '都道府県を選択', selected: @prefecture_code },
          { class: 'select', data: { action: 'change->dependent-form#update' } } %>
    <%= form.collection_select :city_code, @cities, :code, :name,
          { prompt: '市区町村を選択', selected: @city_code },
          { class: 'select', data: { action: 'change->dependent-form#update' } } %>
    <%= form.submit '条件で検索', class: 'btn btn-primary' %>
  <% end %>
</turbo-frame>

<turbo-frame id="search-results">
  <%= render partial: 'results', locals: { listings: @listings } %>
</turbo-frame>
  • セレクト変更時はStimulusがlocation-formフレームを再訪問し、フォーム部品だけを最新化
  • 検索ボタン押下時はdata-turbo-frame="search-results"が効いて、結果一覧だけが差し替わる

4-2. StimulusでURLとフレームを制御

Stimulus側では「URLパラメータを書き換えてTurbo.visitする」だけに徹します。こうしておけば、戻るボタンやリロードでも状態がズレません。

// app/javascript/controllers/dependent_form_controller.ts
import { Controller } from '@hotwired/stimulus';
import { Turbo } from '@hotwired/turbo-rails';

export default class extends Controller {
  static values = { targetFrame: String };

  update(event: Event) {
    // サンプルのため Input, Select のみ対応
    const element = event.target as HTMLInputElement | HTMLSelectElement;
    if (!element?.name) return;

    const url = new URL(window.location.href);
    url.searchParams.set(element.name, element.value);

    // GETリクエストを送信し、対象Turbo Frameを再描画する
    // GETリクエストのため、個人情報などを含むデータでは使用しないこと。
    Turbo.visit(url.toString(), {
      frame: this.targetFrameValue,
      action: 'advance', // 履歴に積んで戻る操作と整合させる
    });
  }
}

4-3. コントローラーはGETで整える

コントローラー側はパラメータを受け取って通常のHTMLを返すだけです。Turbo Frameが同じアクションを再訪問してくれるので、特別なフォーマット分岐は不要です。

# app/controllers/locations_controller.rb
class LocationsController < ApplicationController
  def index
    permitted_params = params.permit(:prefecture_code, :city_code)
    @prefecture_code = permitted_params[:prefecture_code]
    @city_code = permitted_params[:city_code]
    @cities = City.where(prefecture_code: @prefecture_code)
    @listings = Listing.where(prefecture: @prefecture_code, city: @city_code)
  end
end

既存の検索アクションをそのまま流用できるので、テストもindexのHTMLレスポンスを確認するだけで済みます。追加テンプレートが無いため、テンプレート差し替えのレビューコストも下がりますね。

特定のフォームに依存しない汎用的なStimulusコントローラーとなっているので、Viewごとに追加でJavaScriptを書く必要もありません。

5. おわりに

Hotwireには様々な機能が搭載されていますが、できるだけシンプルなTurbo Framesで書いてあげた方が保守性が高まります。Turbo Streamsなどはどうしても必要になった際に初めて導入を検討しましょう。

他にも良いやり方があればぜひ教えてくださいね。ではまた。

Discussion