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への悪影響は最小化できています。