This page looks best with JavaScript enabled

【Rails × Turbo】TurboStreamを使って非同期処理でお気に入りボタン

 ·  ☕ 3 分で読めます
✏️

はじめに

「お気に入り機能」といえば、js.erbファイルを作成してAjaxで切り替える実装が多いのですが、今回はTurboStreamでお気に入りの非同期処理を行うことにしました。

Image from Gyazo

使用技術

  • Ruby 3.0.2
  • Rails 6.1.4
  • turbo-rails 0.5.12

アイコン用にFontAwesomeを使っています。

前提(Turboではなくても共通の部分)

モデル

users -< likes >- articles
という関連になっています。
同一ユーザーが同じ記事をお気に入りすることはないので、一意制約をつけておきます。

1
2
3
4
5
6
class like < ApplicationRecord
  belongs_to :user
  belongs_to :article

  validates :article_id, uniqueness: { scope: :user_id }
end
1
2
3
4
5
6
7
8
class Article < ApplicationRecord
  has_many :likes, dependent: :destroy
  has_many :liking_users, through: :likes, source: :user

  def liked_by?(user)
    liking_users.include?(user)
  end
end

コントローラ

1
2
3
4
5
Rails.application.routes.draw do
  resources :articles do
    resource :like, only: %i[create destroy]
  end
end

記事に対してお気に入りをする / しない用のアクションを定義します。
記事1つに対してお気に入りをするのは1度のみなので、resourceという風に単数形にします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class LikesController < ApplicationController
  before_action :require_login

  def create
    @article = Article.find(params[:article_id])
    current_user.likes.create!(article_id: @article.id)
  end

  def destroy
    @article = Article.find(params[:article_id])
    like = current_user.likes.find_by!(article_id: @article.id)
    like.destroy!
  end
end

同じユーザー・同じ記事に対するLikeは1つしかないことを利用しています。

ビュー

以下のようにしてお気に入りボタンの部分をパーシャルに分けておきます。

current_userというスコープが大きい変数をパーシャルから呼び出したくないので、お気に入りしているかどうかの判定はbooleanとして外部から渡すことにします。
(この辺、ベストプラクティスはあるのだろうか?)

1
<%= render 'likes/like_button', article: @article, liked: article.liked_by?(current_user) %>
1
2
3
4
5
6
7
8
9
<% if liked %>
  <%= link_to article_like_path(article), method: :delete do %>
    <i class="fas fa-heart"></i>
  <% end %>
<% else %>
  <%= link_to article_like_path(article), method: :post do %>
    <i class="far fa-heart"></i>
  <% end %>
<% end %>

ここまでで、「お気に入りをしている場合、していない場合の表示と、ボタンを押したときのサーバーサイド側の処理はできました。
が、現在はお気に入り登録 / 解除した後のレスポンスがないため、リクエストを送った後は自力でブラウザを更新して反映する必要があります。

ここからやっと本題です。

ちなみに、Ajaxで処理する場合は、remote: trueを付けたりcreate.js.erbを作成してレスポンス返すようにします。
非同期にしない場合は、普通にredirect_toでパスを指定すればいいです。

TurboStreamでお気に入り登録 / 解除後の挙動を実装する

<turbo-frame>要素で囲まれた部分がTurboStreamで作用する部分になる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<turbo-frame id="like-button">
  <% if liked %>
    <%= link_to article_like_path(article), method: :delete do %>
      <i class="fas fa-heart"></i>
    <% end %>
  <% else %>
    <%= link_to article_like_path(article), method: :post do %>
      <i class="far fa-heart"></i>
    <% end %>
  <% end %>
</turbo-frame>

コントローラではrenderでTurboStreamの挙動を記述することができる。
今回は、replaceを使うことでパーシャルごと再描画する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class LikesController < ApplicationController
  before_action :require_login

  def create
    @article = Article.find(params[:article_id])
    current_user.likes.create!(article_id: @article.id)

    render turbo_stream: turbo_stream.replace(
      'like-button',
      partial: 'likes/like_button',
      locals: { product: @product, liked: true },
    )
  end

  def destroy
    @article = Article.find(params[:article_id])
    like = current_user.likes.find_by!(article_id: @article.id)
    like.destroy!

    render turbo_stream: turbo_stream.replace(
      'like-button',
      partial: 'likes/like_button',
      locals: { product: @product, liked: false },
    )
  end
end

これによって、<turbo-frame id="like-button">がパーシャルの中身のHTMLで置き換えられる(<turbo-frame>の中が ではなく <turbo-frame>要素ごと 置き換えられるので注意)。

なので、お気に入り登録(create)をしたときのレスポンスとその後のhtmlは以下のようになる。

image

1
2
3
4
5
<turbo-frame id="like-button">
  <a rel="nofollow" data-method="delete" href="/articles/33/like">
    <i class="fas fa-heart" aria-hidden="true"></i>
  </a>
</turbo-frame>

参考

Share on

END
END
@aiandrox

目次