Hotwire 探訪: reform controller

ユーザの入力に応じて動的に表示を切り替えたい場面はよくあります。例えば「その他」を選択したときだけ自由入力欄を表示したい、とかですね。これ簡単そうに見えて意外と難しいというか、考えることが多いのです。動的にということで Stimulus を使うまではよいとしても、コントローラをどの粒度で作るのか、処理をクライアントサイドで閉じるのか Turbo Frame などと組み合わせるかなど常日頃悩ましく思っていました。色々試してみて、こういう状況で使えるシンプルで汎用的なテクニックを見つけたのでご紹介します。

まずこんな感じの Stimulus コントローラを作ります。

// app/javascript/controllers/reform_controller.js
import { Controller } from '@hotwired/stimulus';

export default class ReformController extends Controller {
  static values = {
    visitOptions: Object
  };

  visit() {
    const data = new FormData(this.element);
    const url  = new URL(location.href);

    url.search = new URLSearchParams(data).toString();

    Turbo.visit(url.toString(), this.visitOptionsValue);
  }
}

このコントローラは form 要素に挿して使います。

<%# app/views/wishes/new.html.erb %>
<%= form_with(
  model: @wish,

  data: {
    controller:                 'reform',
    reform_visit_options_value: { frame: 'additional_input' }.to_json
  }
) do |f| %>
  <%= f.select :kind, [
    ['',     'money']
    ['',     'power'],
    ['名声',   'fame'],
    ['その他', 'other']
  ], **{
    data: { action: 'reform#visit' }
  } %>

  <%= turbo_frame_tag 'additional_input' do %>
    <%= if @wish.kind == 'other' %>
      <%= f.text_field :other_text %>
    <% end %>
  <% end %>

  <%= f.submit %>
<% end %>

Rails 側のコントローラはこんな感じです。

# app/controllers/wishes_controller.rb
class WishesController < ApplicationController
  def new
    @wish = Wish.new(wish_params)
  end

  private

  def wish_params
    params.fetch(:wish, {}).permit(:kind, :other_text)
  end
end

これですべてです。どういう風に動くかを見てみましょう。まず、ビューの select を選択したときに reform#visit が呼ばれます。

  <%= f.select :kind, [
    ['',     'money']
    ['',     'power'],
    ['名声',   'fame'],
    ['その他', 'other']
  ], **{
    data: { action: 'reform#visit' }
  } %>

visit は現在の form の内容をクエリ文字列にシリアライズして、今いるページの URL にくっ付けて Turbo.visit() で送信します。今回 turbo_visit_options_value{ frame: 'additional_input' } なので、返ってきた HTML は additional_input という名前の Turbo Frame に挿入されます。

  visit() {
    const data = new FormData(this.element);
    const url  = new URL(location.href);

    url.search = new URLSearchParams(data).toString();

    Turbo.visit(url.toString(), this.visitOptionsValue);
  }
<%= form_with(
  model: @wish,

  data: {
    controller:                 'reform',
    reform_visit_options_value: { frame: 'additional_input' }.to_json
  }
) do |f| %>

「今いるページ」というのはつまり wishes#new です。ここで wish_params を使ってモデルを初期化することで、ユーザの入力内容を反映した状態でフォームが描画されます。つまり、現在の入力内容を元に wishes#new を丸ごと再描画してその一部を Turbo Frame にはめ込んでいるわけです。

  def new
    @wish = Wish.new(wish_params)
  end
  <%= turbo_frame_tag 'additional_input' do %>
    <%= if @wish.kind == 'other' %>
      <%= f.text_field :other_text %>
    <% end %>
  <% end %>

注意点として、wishes#new を直接表示したときは params が空っぽなので params.requireparams.expect だとエラーになります。params.fetch で params が空のときのケアをしてやる必要があります。

  def wish_params
    params.fetch(:wish, {}).permit(:kind, :other_text)
  end

別の例を見てみましょう。一覧の各行にチェックボックスがあって、チェックを付けると一括削除ボタンが active になるような画面を考えます。まず先の reform controller に少し手を入れて、form 要素を target でも指定できるようにします。

// app/javascript/controllers/reform_controller.js
import { Controller } from '@hotwired/stimulus';

export default class ReformController extends Controller {
  static values = {
    visitOptions: Object
  };

  static targets = [
    'form'
  ];

  visit() {
    const form = this.hasFormTarget ? this.formTarget : this.element;
    const data = new FormData(form);
    const url  = new URL(location.href);

    url.search = new URLSearchParams(data).toString();

    Turbo.visit(url.toString(), this.visitOptionsValue);
  }
}

これで form 要素と入力項目が離れていても使えるようになりました。これを使って一覧画面を作ります。

<%# app/views/wishes/index.html.erb %>
<div
  data-controller="reform"
  data-reform-visit-options-value="<%= { frame: 'bulk_destroy' }.to_json %>"
>
  <%= form_with(
    url:    bulk_destroy_wishes_path,
    method: :post,
    id:     'bulk_destroy',
    data:   { reform_target: 'form' }
  ) do |f| %>
    <%= turbo_frame_tag :bulk_destroy do %>
      <%= f.submit '一括削除', disabled: params[:ids].blank? %>
    <% end %>
  <% end %>

  <table>
    <% @wishes.each do |wish| %>
      <tr>
        <td>
          <%= check_box_tag 'ids[]', wish.id, **{
            form: 'bulk_destroy',
            data: { action: 'reform#visit' }
          } %></td>
        </td>

        <td><%= wish.kind %></td>
      </tr>
    <% end %>
  </table>
</div>

チェックボックスをトグルすると reform#visit が呼ばれ、form の内容を元に今の画面を再描画し、最終的に Turbo Frame が更新されて全部辻褄が合う…という一連の流れがおわかりいただけるでしょうか。画面ごとに固有の JavaScript を書く必要もなく、すべてのロジックがサーバサイドに集約されています。

リクエストを GET で送る仕組みの制約として、あまりに巨大なデータやファイルのアップロードは扱えません。そのような場合は万葉の大場さんが紹介されている「Ghost Form パターン」が参考になるかもしれません。

皆さまの快適な Hotwire ライフの一助となれば幸いです。

2025-01-20