サーバ側だけでフォーム画面をインタラクティブに! 〜Hotwire を活用した「Ghost Formパターン」〜
こんにちは、@nay3 です。
私はここ数年、仕事とプライベートの両方で Rails と Hotwire を使ってアプリケーション開発をしていますが、その中で、繰り返し使っているパターンがあります。
そのパターンは、とても便利なのですが、数ヶ月も間が空くと忘れてしまい、つい旧態依然としたコードから出発してまた同じところにたどり着く、ということを繰り返してしまいます。
実は、最近もまた繰り返してしまいました。
そこで、今後はすぐに思い出して再利用できるように、このパターンに「Ghost Form パターン」(※)という名前をつけて、記事化してしまうことにしました!
※Ghost Form という名前については、レビュー協力をいただいた @tanaka51 さんの案を採用させていただきました。ありがとうございます。
ユーザー操作で動的にフォーム画面を変化させたい
今回の「やりたいこと」は、ユーザー操作で動的にフォーム画面を変化させることです。
たとえば、会議の情報を登録する機能があるとします。最初に、会議の種類(Category)を選びます。「オンライン」か「会議室」か「ハイブリッド」を選び、選んだ種類に応じて、「会議室」や「ミーティングURL」といった項目が出現するとします。
本記事では、コードの例示はこのサンプルアプリケーションをベースに行います。https://github.com/everyleaf/ghost_form_example で公開していますので、良かったら動かしたり、コードを確認したりしながら、読み進めてください。
よくある解法 - JSで項目の表示状態を制御する
Railsアプリケーションでこういう機能を実現しようとする場合、一般的な解法のひとつとしては、次のような作戦が知られていると思います。
サーバ側では、すべてのケースで必要となる項目を出力する
クライアント側で、そのときの状況に応じて、各項目を表示するかしないかを制御する
これを実現するには、「画面操作を感知して、特定の項目の表示状態を変える」というJavaScriptコードを書きます。サンプルアプリケーションでは、この解法を「v1」として実装しています。サーバ側を V1::MeetingsController として実装し、クライアント側で動くJavaScriptとして、v1-meetings という名前の Stimulus のコントローラーを以下のように実装しています。
app/javascript/controllers/v1/meetings_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="v1--meetings"
export default class extends Controller {
changeCategory(event) {
const category = event.currentTarget.value
Array.from(document.getElementsByClassName("optional")).forEach((element) => {
element.style.display = "none"
})
Array.from(document.getElementsByClassName(category)).forEach((element) => {
element.style.display = "block"
})
}
}
このJavaScriptを利用する画面(ビュー)の実装は次のようになっています。
app/views/v1/meetings/_form.html.erb
<%= form_with model: @meeting, url: v1_meetings_path, data: { controller: "v1--meetings" } do |f| %>
<div style="margin-bottom: 1rem;">
<div><%= f.label :title %></div>
<div><%= f.text_field :title %></div>
<p style="color: red;"><%= @meeting.errors.full_messages_for(:title).join %></p>
</div>
<div style="margin-bottom: 1rem;">
<div>Category</div>
<%= f.radio_button :category, "real", data: { action: "change->v1--meetings#changeCategory" } %>
<%= f.label :category, "Real", value: "real" %>
<%= f.radio_button :category, "online", data: { action: "change->v1--meetings#changeCategory" } %>
<%= f.label :category, "Online", value: "online" %>
<%= f.radio_button :category, "hybrid", data: { action: "change->v1--meetings#changeCategory" } %>
<%= f.label :category, "Hybrid", value: "hybrid" %>
</div>
<div style="margin-bottom: 1rem; display: <%= %w[real hybrid].include?(@meeting.category) ? "block" : "none" %>;" class="optional real hybrid">
<div><%= f.label :meeting_room %></div>
<div><%= f.text_field :meeting_room %></div>
</div>
<div style="margin-bottom: 1rem; display: <%= %w[online hybrid].include?(@meeting.category) ? "block" : "none" %>;" class="optional online hybrid">
<div><%= f.label :meeting_url %></div>
<div><%= f.text_field :meeting_url %></div>
</div>
<div>
<%= f.submit %>
</div>
<% end %>
会議室の種類によって表示を切り替えたい要素にCSSクラスを与えておき、実際に会議室の種類を切り替える操作をしたときにJavaScript の changeCategory メソッドが呼ばれて、表示の切り替えが行われます。
これで、思い通りの動きをしてくれます。めでたし、めでたし。
めでたし、めでたし、では終わらない
前述の「よくある解法」は、ご覧の通り、Hotwireでスッキリ、サクッと書くことができます。爽快感すら覚えます。
それでも、この「よくある解法」は、最善手とは限らないのです。
なぜ最善手とは限らないのか? 結論から述べると、この解法が、アプリケーションをDRY(※)にできなくなる道に通じているからです。では、なぜDRYにできなくなるのでしょう? その理由は主に2つあります。
クライアント側とサーバ側の両方に同じロジックを書く必要が生じていく
機能ごとにStimulus コントローラーが積み上がり、それをDRYにできない
それぞれ、少し詳しく説明してみます。
※DRY = Don't Repeat Yourselfの略。システム内で各知識が一箇所にだけ書かれているようにしよう、複数の箇所で同じことを書かないようにしよう、という考え方
1. クライアント側とサーバ側の両方に同じロジックを書く必要が生じていく
今回の、「会議種類を選択したら表示する入力欄を切り替えたい」という例は、一見、「クライアント側だけにコードを書けば済む」ように見える課題です。ただし、実は現時点でも、わずかながらサーバ側にもこの問題に関する次のようなロジックを書いています。
画面を初期表示する際にサーバ側で、CSSのdisplayに何を指定するかを制御している
これだけならば、クライアント側によせて、画面をロードしたタイミングでJavaScriptで初期状態のCSSを設定するようにすれば、サーバ側の実装をゼロにできます。よかった、解決しそうですね!
しかし、さらに以下のようなことを実現しようとしたらどうでしょうか?
Strong Parameters(※) において、会議種類に対して有効でない項目のパラメータが入ってきたら無視するようにしたい。
会議データをCSVインポートする機能を追加したい。このとき、会議種類に対して有効でない項目について値が入っていても、DBには取り込まないようにしたい。
※Strong Parameters = ブラウザから受け取ったリクエストパラメータのうち、想定されたデータだけを厳選して使うRailsの仕組み
こうなってくると、結局、サーバ側でも実装をしていかなければなりません。どの会議種類がどの項目を有効とするのかをどこかにRubyで定義して、それに基づいて実装することになるでしょう。
総じて、Railsアプリケーションではクライアント側だけにロジックを寄せられる課題というのは極めて限定的であり、多くの課題はビジネスロジックとしてサーバ側とも本質的に関係すると私は考えます。最初はクライアント側だけに分離して実装できていたものが、Web画面以外のデータ入力(CSVインポートや、テストデータの作成等)を充実させるにつれ、サーバ側にも同種のロジックが書かれていくということが起こりがちです。
つまり、「クライアント側にロジックを書く」という最初の一手が、やがて、同種のロジックをクライアント側(JavaScript)・サーバ側(Ruby)の2系統で実装し、メンテナンスし続ける状態へつながっていくということです。一度こうなってしまうと、ロジックを変更する際につねに複数箇所を変更せねばならず、コードがDRYでないことの苦労を負い続けることになります。
2. 機能ごとにStimulus コントローラーが積み上がり、それをDRYにできない
DRYにできない2つめの理由は、アプリケーションの成長とともに、機能ごとの Stimulus コントローラが積み上がっていきやすいことに起因します。
たとえば、「よくある解法」で会議室の課題を解決した後で、「ユーザーの編集画面で有料会員チェックボックスをクリックしたら、フォーム内の項目構成を変えたい」というニーズが新たに生まれたらどうなるでしょうか。おそらく、v1/meetings_controller.js を参考にして、users_controller.js を作り、changeUserRank といったメソッドを作ることになるでしょう。
このとき、会議管理のための changeCategory メソッドと、ユーザー管理のための changeUserRank メソッドは、それぞれの機能の具体的なニーズに合わせて実装されており、やりたいことは似ているけれども具体的な内容が少し違ったり、片方には独自の何かが追加されていたりと、共通化しづらい状態になりがちです。
このように、「よくある解法」を素直に実践すると、それを参考に後続のコードが書かれていくという作用によって、Rails の機能(概ね Controller)ごとにStimulus のコントローラーが作られていくことや、そこに機能固有の挙動としていろいろなロジックが書かれることを誘導・促進してしまうのです。
一般的に、Stimulusコントローラー内のロジック同士は、流れが似ていたとしても、要素の具体的な構成がそれぞれ異なるため、コードをまとめることが困難です。実は、Stimulus の target などの機能を使えば、抽象度が高い設計で汎用的に作り込むことも可能ではあります。しかし、具体的・個別的なJavaScriptコードを書くほうが手軽なので、どうしてもそういうコードが増えてしまう傾向があります。
また、仮に完全に同じJavaScriptコードが複数の Stimulus コントローラーに現れていたとしても、ある機能の担当者が自力で別の機能の JavaScript から同じ部分を発見して共通化するということも、相当に難しいと思います。Stimulus である程度整理が容易になったとはいえ、RailsアプリケーションにおけるJavaScript はあくまでも副次的な位置付けであり、意味に沿った高度な構造化がしやすい環境ではないからです。
こういった事情から、アプリケーションの成長とともに、Stimulus コントローラの数が増え、それらのコードをそれ以上DRYに構造化できる見込みがないまま、ずっとメンテナンスしていかなければならなくなります。これは、相当な重荷で、開発速度や品質を下げる要因になります。
このように、「よくある解法」はアプリケーションをDRYにできなくなる道に導いていきます。最終的には、React などで作り直すしか救われる道はない、という思いを開発者に抱かせることになりかねません。それは、Rubyを愛する私にとっては悲しい事態です。
ですが、実はほかにも道があるのです。それが、私が心の中で名付けている「Hotwire光の道」です。
Hotwire光の道
私の考える「Hotwire光の道」、それは、サーバに処理を集めるという設計原則です。
今回の課題を例にしてご紹介したい「Ghost Formパターン」は、その設計原則の一つの具体例となっています。
Ghost Form パターンとは
今回のように「ユーザー操作で動的にフォーム画面を変化させる」課題を実現したい時に、「よくある解法」の罠を避けて、どのように進めるか? ということで私が密かに愛用しているパターンがあります。今回、それを「Ghost Form(幽霊フォーム)」パターンと勝手に名付けてみました(すでに名前があるようでしたら教えてください)。
なぜこのような名前をつけているかというと、下図のように、「本来の処理をおこなうフォーム」にそばに「Ghost Form」つまり「影に潜む幽霊のように、こっそり画面更新のためのリクエストを飛ばすためのフォーム」を設置するからです。
本来の処理を行うフォーム内でユーザーが操作を行うと、JavaScriptイベントが発行されます。これを GhostFormController (JavaScript) でハンドルして、Original Form の内容を Ghost Form にコピーし、画面更新用のRailsアクション(上図では「refresh」)に送信します。
画面更新用のRailsアクションは、送られてきたパラメータをもとに、もう一度フォーム画面 render します。この時、Turbo Frames を使っていれば、Turbo Frames 内だけが書き換えられてより快適ですが、画面全体を書き換えても、目的は実現できます。(もちろん、状況によっては Trubo Streams を使えば、メンテナンス性とトレードオフにはなりますが、より限定的な範囲を書き換えるといったことを通じてユーザビリティをさらに向上させられるかもしれません。)
これを実現する JavaScript の GhostFormController のコード例は、次のようになります。
app/javascript/controllers/ghost_form_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="ghost-form"
export default class extends Controller {
static targets = [ "originalForm", "ghostForm" ]
submit(event) {
const formData = new FormData(this.originalFormTarget)
formData.delete("_method")
formData.delete("authenticity_token")
for (const [key, value] of formData.entries()) {
const ghost_key = "ghost_" + key
const input = this.ghostFormTarget.querySelector(`input[name="${ghost_key}"]`)
if (input) {
input.value = value;
}
}
this.ghostFormTarget.requestSubmit()
}
}
GhostFormController では、submitメソッドを用意しています。このメソッドは、本来の処理を行うフォーム(originalForm)の内容をGhost Form にコピーしてからsubmitします。
このJavaScriptを利用するビューの実装は次のようになっています。
app/views/v2/meetings/_form.html.erb
<turbo-frame id="meeting-form" data-controller="ghost-form">
<%= form_with model: @meeting, url: v2_meetings_path, data: { "turbo-frame" => "_top", "ghost-form-target" => "originalForm" } do |f| %>
<div style="margin-bottom: 1rem;">
<div><%= f.label :title %></div>
<div><%= f.text_field :title %></div>
<p style="color: red;"><%= @meeting.errors.full_messages_for(:title).join %></p>
</div>
<div style="margin-bottom: 1rem;">
<div>Category</div>
<%= f.radio_button :category, "real", data: { action: "ghost-form#submit" } %>
<%= f.label :category, "Real", value: "real" %>
<%= f.radio_button :category, "online", data: { action: "ghost-form#submit" } %>
<%= f.label :category, "Online", value: "online" %>
<%= f.radio_button :category, "hybrid", data: { action: "ghost-form#submit" } %>
<%= f.label :category, "Hybrid", value: "hybrid" %>
</div>
<% if %w[real hybrid].include?(@meeting.category) %>
<div style="margin-bottom: 1rem;">
<div><%= f.label :meeting_room %></div>
<div><%= f.text_field :meeting_room %></div>
</div>
<% end %>
<% if %w[online hybrid].include?(@meeting.category) %>
<div style="margin-bottom: 1rem;">
<div><%= f.label :meeting_url %></div>
<div><%= f.text_field :meeting_url %></div>
</div>
<% end %>
<div>
<%= f.submit %>
</div>
<% end %>
<%= form_with scope: "ghost_meeting", model: @meeting, url: refresh_new_v2_meeting_path, method: :post, data: { "ghost-form-target" => "ghostForm" } do |f| %>
<%= f.hidden_field :title %>
<%= f.hidden_field :category %>
<%= f.hidden_field :meeting_room %>
<%= f.hidden_field :meeting_url %>
<% end %>
</turbo-frame>
ビューのコードをよくある解法(v1)のときと比べると、似ている点と、異なる点があります。
似ている点
data-controller で、Stimulus コントローラと紐づける
会議の種類を選ぶラジオボタンで発生するイベントで JavaScript アクションを呼ぶ
異なる点
v1では、サーバは全項目を出力していたが、v2では、そのときの会議種類にあう項目だけを出力している
v1では、表示制御用のCSSクラスをつけていたが、v2では、つけていない
v2には、2つめの form 要素(Ghost Form)がある
v2は、Turbo Frames を使っている
このビューの Ghost Formから JavaScript によって送られたデータを使ってフォームを再表示するための refresh アクションの実装は、次のようになっています。
app/controllers/v2/meetings_controller.rb
class V2::MeetingsController < ApplicationController
...
def refresh
@meeting = Meeting.new(meeting_params(scope: :ghost_meeting))
render :new
end
private
def meeting_params(scope: :meeting)
params.require(scope).permit(:title, :category, :meeting_room, :meeting_url)
end
...
end
refreshアクションでは、単に、送られてきたデータを受け取って、newアクション(最初に会議室登録フォームを表示するためのアクション)と同じビューを render しています。(※)
※ちなみに、本来の処理を行うフォームと Ghost Form 内の input要素の id属性が重複するのをさけるため、Ghost Form からは params[:ghost_meeting]… といった形でデータを送るようにしています。そのため、StrongParameters の対応箇所である meeting_params メソッドでは、この名前の違いを吸収できるようにしています。
Ghost Formパターンのデメリット
一見すると、Ghost Form パターンは大掛かりに見えるかもしれません。少なくとも、単純に前述の「よくある解法」と比べると、コードが多くなっていると思います。
ただし、一度このパターンを導入してしまえば、ghost_form_controller.js は、各機能で共通利用できます。そのため、実はそこまで冗長ではありません。Formを毎回2つ作るところは少し面倒で、DRYでない面はありますが、ビューに並べて書けるのでセットでメンテナンスすることは容易であり、項目を変更する頻度の面からも、許容範囲かと思っています。(※)
※ちなみに、なぜFormをわけているかというと、アクション設計の自由度が高いし、CSRF関係の問題をシンプルにクリアできるからです。
そのほかのデメリットとしては、フォームがインタラクティブに更新される速度が「よくある解法」の場合よりも遅くなるという点があります。なぜ遅くなるかというと、「よくある解法」ではクライアント側だけで解決していた問題を、Ghost Formパターンではいちいちサーバ側に行わせているからです。サーバが物理的に離れたところにあれば、ワンテンポ遅れた感じの更新になってしまいます。これは確かにデメリットであり、コード全体のメンテナンス性や開発効率と引き換えることは十分検討できるものの、少し残念な点ではあるでしょう。
Ghost Formパターンのメリット
一方、メリットは豊富にあります。
最大のメリットは、「Hotwire光の道」に沿っている、つまりフォーム画面の制御にまつわるロジックをサーバ側に集約できることです。これは、次のような効果を生み出します。
クライアント側(JavaScript)とサーバ側(Ruby)で二重にロジックを記述しなくて済む。DRYな構造を保てる。
機能ごとに Stimulus の コントローラー (JavaScript) が作られ、似て非なる制御をあれこれするコードが積み上がることを誘導・促進しない。将来的に一貫性のある変更が難しくなっていくことや、管理対象のコードが増えることの抑制に貢献できる。
画面制御が、サーバから画面を初期表示するコードだけで実現できるようになり、シンプルになる。
画面制御がサーバ側だけでできるようになるので、Rubyによるコード整理に "限界" がなくなる。Module を利用したり、MVCの各層で適切に分担することで、DRYな、柔軟なコード状態にすることができる。もちろん、メタプロもDSLもお好みのまま。
つまり、このパターンは、従来はクライアント側でJavaScriptを用いて対処しなければならなかった「インタラクティブな画面づくり」という課題を、サーバ側でRubyを用いて一元的に解くことができる一例になっています。
まとめ - RailsとRubyの力を最大限に活かそう
私は、今回紹介した Ghost Form パターンのほかにも色々なパターンを試作・実践しているので、機会があれば紹介したいと思っています。そういったパターンに共通しているのは、「サーバに寄せる」つまり、Rails と Ruby ですべてをコントロールできるようにするという「Hotwire光の道」の設計原則です。
思えば、Hotwire以前の世界でそういうアプローチが実現できなかったのは、「毎回画面を書き換える」ことのデメリットが大きかったからです。Hotwireは本質的に、このデメリットを小さくする装置です。ですから、Hotwireを利用して「Railsセントリック」なアーキテクチャに揃えていくことは、とても理にかなっているのではないかと私は考えています。
この記事が、みなさんのHotwire活用のヒントになれば幸いです。
株式会社万葉ではエンジニアの採用を行っています!
https://everyleaf.com/we-are-hiring