コード例
ここでは「いいね」ボタンの実装を通して、UI/UXを段階的に改善していきます。最終的にはoptimistic UI (楽観的UI)まで実装し、ネイティブアプリのような操作性を実現します。
下記のようなUIになります。
<% highlight = local_assigns.fetch(:highlight, false) %> <tr class="group p-2" id="<%= dom_id(todo) %>"> <td class="<%= 'highlight-on-appear' if highlight %> p-2 border-gray-400 border-t group-[:first-child]:border-none"> <div class="flex"> <div class="flex grow items-center"> <%= render 'like_button', todo: %> <%= todo.title %> <!-- ... --> </div> <!-- ... --> </div> </td> </tr>
app/views/todos/_todo.html.erb
partialですrender 'like_button', todo:
で「いいね」ボタンを表示しています。今回はmpa, streams, optimisticの3つのバージョンがあります <% todo = local_assigns.fetch(:todo) %> <%= tag.div class: "flex items-center w-16 aria-busy:opacity-30" do %> <%= label_tag nil, class: "group flex cursor-pointer select-none" do %> <% if todo.liked_by?(current_user) %> <%= button_to todo_likes_path(todo), method: :post do %> <%= liked_icon %> <% end %> <% else %> <%= button_to todo_likes_path(todo), method: :post, params: { like: "1" } do %> <%= unliked_icon %> <% end %> <% end %> <div> : <span><%= todo.likes_count %></span> </div> <% end %> <% end %>
todo.liked_by?(current_user)
のところで「いいね」済みかどうかを確認し、それに応じて異なる「いいね」ボタンを表示していますtodo_likes_path
にPOSTリクエストを送信していますclass Todos::LikesController < ApplicationController # ... def create sleep 1 if params[:like] @todo.like_by! current_user else @todo.unlike_by! current_user end if request.variant.mpa? return redirect_to todos_path end end private def set_todo @todo = Todo.find(params[:todo_id]) end end
def create
のところが「いいね」ボタンのアクションを受け取るメソッドになりますreturn redirect_to todos_path
をしています。いわゆるPOST/redirect/GETのパターンですapp/views/todos/index.html.erb
でturbo_refreshes_with method: :morph, scroll: :preserve
を設定しているため、Morphingを使った再レンダリングをしています。そのためスクロール位置は維持されます<% todo = local_assigns.fetch(:todo) %> <%= tag.div id: dom_id(todo, :like_button), class: "flex items-center w-16" do %> <%= label_tag nil, class: "group flex cursor-pointer select-none" do %> <% if todo.liked_by?(current_user) %> <%= button_to todo_likes_path(todo), method: :post do %> <%= liked_icon %> <% end %> <% else %> <%= button_to todo_likes_path(todo), method: :post, params: { like: "1" } do %> <%= unliked_icon %> <% end %> <% end %> <div> : <span><%= todo.likes_count %></span> </div> <% end %> <% end %>
tag.div id: dom_id(todo, :like_button)
のところでIDをつけていますコントローラは上述のものと同じです。ただしrequest.variant.mpa?
はfalseを返しますので、app/views/todos/likes/create.turbo_stream.erb
をテンプレートとしたレスポンスを返します。
<%= turbo_stream.replace dom_id(@todo, :like_button) do %> <%= render partial: "todos/like_button", locals: { todo: @todo } %> <% end %>
app/views/todos/likes/create.turbo_stream.erb
では “todos/like_button"のpartialを返します。今回は"streams"のvariantを使いますので、app/views/todos/_like_button.html+streams.erb
を返します<% todo = local_assigns.fetch(:todo) %> <%= form_with id: dom_id(todo, :like_button), url: todo_likes_path(todo), method: :post, class: "flex items-center w-16 aria-busy:opacity-30", data: { controller: "todo-likes", action: "submit->todo-likes#optimistic" } do %> <%= label_tag nil, id: dom_id(todo, :like_button), class: "group flex cursor-pointer select-none" do %> <%= check_box_tag :like, "1", todo.liked_by?(current_user), class: "opacity-0 w-0", data: { action: "change->todo-likes#submit", todo_likes_target: "checkbox" } %> <div class="hidden group-has-[:checked]:block"> <%= liked_icon %> </div> <div class="block group-has-[:checked]:hidden"> <%= unliked_icon %> </div> <% end %> <div> : <span data-todo-likes-target="count"><%= todo.likes_count %></span> </div> <% end %>
checked
属性)はCSS擬似セレクタで読み取れますので、周辺の表示も楽観的に変えられます(group-has-[:checked]:block/hidden
の箇所)data-action="submit->todo-likes#optimistic
で行います<form>
にaria-busy
が自動的につくのを利用して、aria-busy:opacity-30
で行います。 <form>
の中に配置された<button>
を使用しましたので、クリックイベントを受け取り、サーバにリクエストを投げるのはブラウザネイティブな機能でやってくれました
data-action="change->todo-likes#submit
でform
の自動送信を行います<form>
<button>
を使わなくしたためにデータの送信にStimulusが必要になりました。 また「いいね」数の楽観的な更新はStimulusを使う必要がありますimport { Controller } from "@hotwired/stimulus" // Connects to data-controller="todo-likes" export default class extends Controller { static targets = ["count", "checkbox"] connect() { } optimistic(event) { let count = this.countTarget.textContent if (this.checkboxTarget.checked) { count++ } else { count-- } this.countTarget.textContent = count } submit(event) { event.currentTarget.form.requestSubmit() } }
targets
のcount
は「いいね」数を表示する場所、checkbox
は「いいね」したかどうかのステートを保持するチェックボックスですsubmit
とoptimistic
の2つがあります
submit
はチェックボックスのステートが変更されたらform
を自動送信するものです(チェックがついたり、消えたりした時)optimistic
は"count” targetの値を楽観的に更新するものですこのようにちょっとした楽観的UI(optimistic UI)もHotwireで簡単に実装できます。もちろんやることは増えますが、特別に苦労するものではありません。UI/UX効果は大きいので、ポイントポイントではおすすめです。
なお、Reactのカナリア版(2024年12月4日現在)でもuseOptimistic
が用意されていて、楽観的UIのサポートがもうすぐ使えそうです。Hotwireの考え方とはかなり異なります。Hotwireの場合はより直接的に楽観的UIを実装しています。