Apple Store模写(複雑なステート)
ここでは価格変更の計算やステートの保持をすべてサーバに持たせる例を紹介します。
デモはこちらに用意しています。
class IphonesController < ApplicationController layout "iphone" before_action :set_iphone def show end # ... private def set_iphone session[:iphone] ||= {} @iphone = Iphone.new(session[:iphone]) end end
IphonesController#showをエンドポイントとします@iphoneインスタンスはIphoneオブジェクトのインスタンスです。DBを使わずに、ステートはすべてsessionで管理ます。そのためIphoneインスタンスはsessionを使って初期化しますclass Iphone def initialize(iphone_session) @iphone_session = iphone_session @catalog = Catalog.new end def state case when @iphone_session["ram"] then :ram_entered when @iphone_session["color"] then :color_entered when @iphone_session["model"] then :model_entered else :nothing_entered end end def model_enterable? true end def color_enterable? state.in? [ :model_entered, :color_entered, :ram_entered ] end def ram_enterable? state.in? [ :color_entered, :ram_entered ] end def model=(string) return unless model_enterable? @iphone_session["model"] = string end def model @iphone_session["model"] end def color=(string) return unless color_enterable? @iphone_session["color"] = string end def color @iphone_session["color"] end def ram=(string) return unless ram_enterable? @iphone_session["ram"] = string end def ram @iphone_session["ram"] end def color_name @catalog.color_name(color) end def image_path @catalog.image_path(model, color) end def pricing @catalog.pricing(model, ram) end def to_hash { model:, color:, color_name: } end end
Iphoneクラスには注文状況とそこから導出される関連情報が収まっています
#color_enterable? #ram_enterable?)#[...]=)Iphone::Catalogクラスに移譲 (#color_name image_path)Iphone::Catalogクラスに移譲 (#pricing)<% local_assigns => {catalog:, iphone:} %> <!-- ... --> <%= form_with url: iphone_path, method: :post do %> <%= fieldset_tag nil, disabled: !iphone.model_enterable?, class: "disabled:opacity-30" do %> <% [{ model: "6-1inch", title: "オラのスマホ Pro", subtitle: "6.1-inch display" }, { model: "6-7inch", title: "オラのスマホ Pro Max", subtitle: "6.7-inch display" }].each do |attributes| %> <%= render 'option', name: :model, value: attributes[:model], selected: iphone.model == attributes[:model], title: attributes[:title], subtitle: attributes[:subtitle], pricing_lines: item_pricing(attributes[:model], iphone.ram, catalog) %> <% end %> <% end %> <% end %> <!-- ... -->
<%= label_tag [name, value].join('_'), class: "mt-4 flex justify-between items-center p-4 block border-2 rounded-lg w-full cursor-pointer has-[:checked]:border-blue-500" do %> <%= radio_button_tag name, value, selected, class: "hidden", onchange: "this.form.requestSubmit()" %> <div> <div class="text-lg"><%= title %></div> <% if subtitle %> <div class="text-sm text-gray-500"><%= subtitle %></div> <% end %> </div> <div> <% pricing_lines.each do |line| %> <div class="text-xs text-gray-500 text-right"><%= line %></div> <% end %> </div> <% end %>
app/views/iphones/_option.html.erbの中でradio_button_tagとして実装しています。radioを使いますので、楽観的UIはブラウザネイティブのものが使えますradio_buttonが変更されたらonchangeでformをsubmitします。本来ならばStimulus Controllerを作って、それを呼び出すべきかもしれません。しかしこれぐらいに簡単な場合はさすがに面倒なので、私はインラインJavaScriptで済ませることも多いですhas-[:checked]:border-blue-500で処理されます。radio_buttonのcheckは楽観的に入りますので、この枠も楽観的UIですapp/views/iphones/_iphone.html.erbに記されている普通のform_withで実装しています。Turboがインストールされていますので、submitされると非同期でサーバにリクエストを送信しますclass IphonesController < ApplicationController layout "iphone" before_action :set_iphone before_action :set_catalog # ... def create @iphone.model = params[:model] if params[:model] @iphone.color = params[:color] if params[:color] @iphone.ram = params[:ram] if params[:ram] respond_to do |format| format.turbo_stream end end # ... end
IphonesController#createに来ます@iphone (Iphoneクラスのインスタンス)にparamsが渡され、ブラウザで選択されたオプションがsessionに反映されますturbo_streamsで応答しています。これは規約に従ってcreate.turbo_stream.erbをテンプレートとして使用します<%= turbo_stream.replace "iphone", method: "morph" do %> <%= render "iphone", iphone: @iphone, controller: @catalog %> <% end %>
iphoneの場所に、partialのiphoneを入れ替えています。iphone partialはフォーム全体をカバーしています。つまり更新された内容でフォーム全体を描き直していますmethod: "morph"をしていますので、単純にDOMを新しいものと入れ替えるのではなく、変更された箇所だけを入れ替えます。ブラウザのステートをなるべくそのままにしますので、よりスムーズなUI/UXになります
<%= tag.div data: { controller: "color-changer", color_changer_iphone_value: iphone } do %> <div class="text-xl my-4" data-color-changer-target="colorText"><%= iphone.color_name %></div> <%= form_with url: iphone_path, method: :post do %> <%= fieldset_tag nil, disabled: !iphone.color_enterable?, class: "disabled:opacity-30" do %> <% [{ color: "naturaltitanium", class: "bg-gray-400" }, { color: "bluetitanium", class: "bg-indigo-800" }, { color: "whitetitanium", class: "bg-white" }, { color: "blacktitanium", class: "bg-black" }].each do |attributes| %> <%= render 'color_option', value: attributes[:color], color: attributes[:class], iphone: iphone %> <% end %> <% end %> <% end %> <% end %>
<% local_assigns => {value:, color:, iphone:} %> <%= label_tag [:color, value].join('_'), data: { action: "mouseenter->color-changer#setColorText mouseleave->image-switcher#resetColorText", color_changer_color_name_param: iphone.color_name_for_value(value) }, class: "#{color} inline-block w-8 h-8 border-2 rounded-full cursor-pointer outline-2 outline outline-offset-0.5 outline-transparent has-[:checked]:outline-blue-500" do %> <%= radio_button_tag :color, value, iphone.color == value, class: "hidden", onchange: "this.form.requestSubmit()" %>  <% end %>
_color_option.html.erb partialで書いていますColorChangerController Stimulus Controllerが担当します
data-color-changer-target="colorText"の箇所ですdata-action="mouseenter->color-changer#setColorText mouseleave->image-switcher#resetColorText"で、mouseenter, mouseleaveイベントに応じて呼び出されますdata-color-changer-color-name-paramで指定していますimport { Controller } from "@hotwired/stimulus" // Connects to data-controller="color-changer" export default class extends Controller { static values = {iphone: Object} static targets = ["colorText"] connect() { } setColorText(event) { const colorName = event.params.colorName this.colorTextTargets.forEach(target => target.textContent = colorName) } resetColorText(event) { this.colorTextTargets.forEach(target => target.textContent = this.iphoneValue.color_name) } }
setColorText, resetColorTextが呼び出され、Targetの内容を直接更新するものです
params.colorNameから読み込んでいますformを送信するだけです。ラジオボタンを押した時にformを自動的に送信するインラインJavaScriptを書いているのみで、ほとんど何もしていません(今回はインラインで書ける程度のJavaScriptでしたので、Stimulusも省略しました)radioで実装していますので、コードを書かなくても楽観的UIが実現できます。CSS擬似要素の:checkedて適宜UIを更新しますIphoneオブジェクトを作り、更新しているだけです。Railsのごく一般的なControllerですIphoneクラスに集約されています
Iphoneクラスに、カタログに関する情報はIphone::Catalogクラスに分けています app/views/iphones/create.turbo_stream.erb)
上述のように、製品オプションを選択するたびにサーバ通信をするやり方であっても、UI/UX上は特に問題になりません。楽観的UIも実現していますので、ネットワークが遅くてもUI/UXへの悪影響は最小化できています。