05. いいね機能とTurbo Streamでの非同期更新
はじめに
前回は、04. 投稿詳細・Xシェア・共通partialを作るで、投稿詳細ページとXシェア機能を作りました。
また、投稿一覧と投稿詳細で同じ表示を使えるように、投稿表示をpartialに切り出しました。
今回は、SNSらしい機能として「いいね機能」を作ります。
やることを整理すると以下です。
- Likeモデルを作る
- UserとPostにLikeの関連付けを追加する
- 1ユーザーが1投稿に1回だけいいねできるようにする
- いいね作成・解除用のルーティングを追加する
- いいねボタンをpartialにする
- Turbo Streamでいいね部分だけ非同期更新する
前回作った投稿partialに、いいねボタンを追加していきます。
ページ全体を再読み込みせず、ハート部分だけが切り替わる形を目指します。
いいね機能の全体像
今回作るいいね機能では、users と posts の間に likes テーブルを作ります。
イメージは以下です。
User
└── likesを複数持つ
Post
└── likesを複数持つ
Like
├── 1人のuserに属する
└── 1つのpostに属する
likes テーブルには、以下の情報を保存します。
- 誰がいいねしたか
- どの投稿にいいねしたか
つまり、user_id と post_id を保存することで、いいねの状態を表します。
Likeモデルを作成する
まず、Likeモデルを作成します。
# ターミナル
bin/rails g model Like user:references post:references
このコマンドで、Like モデルと likes テーブル用のmigrationファイルが作成されます。
user:references と post:references を指定しているため、user_id と post_id を持つテーブルになります。
参考: Railsガイド - Active Record マイグレーション
migrationを確認する
生成されたmigrationファイルに同じユーザーが同じ投稿に何度もいいねできないようにするためのインデックスを追加します。
# db/migrate/XXXXXXXXXXXXXX_create_likes.rb
class CreateLikes < ActiveRecord::Migration[8.0]
def change
create_table :likes do |t|
t.references :user, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.timestamps
end
add_index :likes, [:user_id, :post_id], unique: true
end
end
追加したのは以下です。
add_index :likes, [:user_id, :post_id], unique: true
これは、user_id と post_id の組み合わせを一意にするための設定です。
たとえば、以下は保存できます。
user_id: 1, post_id: 5
user_id: 2, post_id: 5
別のユーザーが同じ投稿にいいねするのは問題ありません。
一方、以下のように同じユーザーが同じ投稿に2回いいねすることは防ぎます。
user_id: 1, post_id: 5
user_id: 1, post_id: 5
モデル側のバリデーションだけでなく、DB側にも制約を入れておくことで、二重送信などが起きた場合にも重複を防ぎやすくなります。
migrationを実行する
migrationを実行します。
# ターミナル
bin/rails db:migrate
これで、likes テーブルが作成されます。
Likeモデルにバリデーションを追加する
次に、Like モデルを修正します。
# app/models/like.rb
class Like < ApplicationRecord
belongs_to :user
belongs_to :post
validates :post_id, uniqueness: { scope: :user_id }
end
belongs_to :user は、いいねが1人のユーザーに属することを表します。
belongs_to :post は、いいねが1つの投稿に属することを表します。
以下のバリデーションで、同じユーザーが同じ投稿に複数回いいねできないようにしています。
validates :post_id, uniqueness: { scope: :user_id }
scope: :user_id を指定することで、user_id ごとに post_id の重複を防ぎます。
Userモデルに関連付けを追加する
User モデルにLikeとの関連付けを追加します。
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :posts, dependent: :destroy
has_many :likes, dependent: :destroy
has_many :liked_posts, through: :likes, source: :post
has_one_attached :avatar
validates :account_name, presence: true, uniqueness: true
end
追加したのは以下です。
has_many :likes, dependent: :destroy
has_many :liked_posts, through: :likes, source: :post
has_many :likes によって、ユーザーが複数のいいねを持てるようになります。
dependent: :destroy を付けているため、ユーザーが削除されたときに、そのユーザーのいいねも一緒に削除されます。
liked_posts は、ユーザーがいいねした投稿一覧を取得するための関連付けです。
current_user.liked_posts
このように書くと、ログイン中ユーザーがいいねした投稿を取得できます。
参考: Railsガイド - Active Record の関連付け
Postモデルに関連付けを追加する
Post モデルにもLikeとの関連付けを追加します。
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many_attached :images
has_many :likes, dependent: :destroy
validates :content, :images, presence: true
end
追加したのは以下です。
has_many :likes, dependent: :destroy
投稿は複数のユーザーからいいねされるため、has_many :likes にしています。
dependent: :destroy を付けているため、投稿が削除されたときに、その投稿に紐づくいいねも一緒に削除されます。
ルーティングを追加する
次に、いいね作成・解除用のルーティングを追加します。
いいねは投稿に紐づくため、posts の中にネストします。
# config/routes.rb
Rails.application.routes.draw do
devise_for :users
root "home#index"
resource :profile, only: [:show, :edit, :update]
resources :posts, only: [:index, :new, :create, :show] do
resource :like, only: [:create, :destroy]
end
end
追加したのは以下です。
resources :posts, only: [:index, :new, :create, :show] do
resource :like, only: [:create, :destroy]
end
ここでは resources :likes ではなく、単数形の resource :like にしています。
理由は、1人のユーザーが1つの投稿に対して持つLikeは1件だけだからです。
このルーティングによって、以下のようなパスが使えるようになります。
POST /posts/:post_id/like
DELETE /posts/:post_id/like
投稿IDが 8 の場合は、以下のようなURLになります。
POST /posts/8/like
DELETE /posts/8/like
LikesControllerを作成する
いいね作成・解除用のコントローラを作成します。
# ターミナル
bin/rails g controller Likes
作成された LikesController を修正します。
# app/controllers/likes_controller.rb
class LikesController < ApplicationController
before_action :authenticate_user!
def create
@post = Post.find(params[:post_id])
current_user.likes.find_or_create_by!(post: @post)
respond_to do |format|
format.turbo_stream
format.html { redirect_to post_path(@post) }
end
end
def destroy
@post = Post.find(params[:post_id])
current_user.likes.find_by(post: @post)&.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to post_path(@post) }
end
end
end
create では、URLの post_id から投稿を取得しています。
@post = Post.find(params[:post_id])
posts に like をネストしているため、投稿IDは params[:post_id] に入ります。
次に、ログイン中のユーザーに紐づくLikeを作成します。
current_user.likes.find_or_create_by!(post: @post)
find_or_create_by! を使うことで、すでに同じLikeがある場合はそれを使い、まだない場合だけ作成します。
destroy では、ログイン中ユーザーがその投稿に付けたLikeを探して削除します。
current_user.likes.find_by(post: @post)&.destroy!
&. を使っているため、すでに解除済みでLikeが見つからない場合でもエラーになりにくくなります。
respond_toでTurbo Streamに対応する
今回の中心になるのが、以下の部分です。
respond_to do |format|
format.turbo_stream
format.html { redirect_to post_path(@post) }
end
format.turbo_stream を書くことで、Turbo Stream形式のリクエストに対応できます。
Railsは、create アクションなら以下のファイルを探します。
app/views/likes/create.turbo_stream.erb
destroy アクションなら以下のファイルを探します。
app/views/likes/destroy.turbo_stream.erb
format.html は、通常のHTMLリクエスト用の戻り先です。
Turbo Streamが使われない場合でも、投稿詳細ページに戻れるようにしています。
参考: Railsガイド - Rails で JavaScript を利用する
いいねボタンのpartialを作成する
いいねボタンは、一覧ページと詳細ページの両方で使います。
そのため、partialに切り出します。
app/views/likes/_like_button.html.erb を作成します。
<!-- app/views/likes/_like_button.html.erb -->
<%= turbo_frame_tag dom_id(post, :like_button) do %>
<% if current_user.likes.exists?(post_id: post.id) %>
<%= button_to post_like_path(post), method: :delete, class: "like-button" do %>
<%= image_tag "heart-active.svg", size: "28x28", alt: "いいね済み" %>
<% end %>
<% else %>
<%= button_to post_like_path(post), method: :post, class: "like-button" do %>
<%= image_tag "heart.svg", size: "28x28", alt: "いいねする" %>
<% end %>
<% end %>
<% end %>
今回は、以下の画像を使います。
# app/assets/images
heart.svg
heart-active.svg
heart.svg は、まだいいねしていない状態の画像です。
heart-active.svg は、いいね済みの状態の画像です。
turbo_frame_tagで更新対象を作る
いいねボタンpartialの一番外側を、turbo_frame_tag で囲んでいます。
<%= turbo_frame_tag dom_id(post, :like_button) do %>
これは、Turbo Streamで置き換える対象を作るためです。
たとえば、投稿IDが 8 の場合、以下のようなIDになります。
like_button_post_8
あとで turbo_stream.replace を使って、この部分だけを差し替えます。
ページ全体を再読み込みするのではなく、いいねボタン部分だけを更新するための目印です。
投稿partialからいいねボタンを呼び出す
前回作成した投稿表示partialから、いいねボタンpartialを呼び出します。
<!-- app/views/commons/_post.html.erb -->
<article class="post">
<div class="post-header">
<% if post.user.avatar.attached? %>
<%= image_tag post.user.avatar.variant(resize_to_fill: [50, 50]), class: "post-avatar" %>
<% else %>
<%= image_tag "default_avatar.png", size: "50x50", class: "post-avatar" %>
<% end %>
<div>
<p><%= post.user.account_name %></p>
<p><%= post.created_at.strftime("%Y/%m/%d %H:%M") %></p>
</div>
</div>
<p><%= post.content %></p>
<% if post.images.attached? %>
<div class="post-images">
<% post.images.each do |image| %>
<%= image_tag image.variant(resize_to_fill: [200, 200]), class: "post-image" %>
<% end %>
</div>
<% end %>
<div class="post-actions">
<%= render "likes/like_button", post: post %>
</div>
</article>
追加したのは以下です。
<div class="post-actions">
<%= render "likes/like_button", post: post %>
</div>
前回、投稿一覧と投稿詳細の両方で commons/_post.html.erb を使うようにしました。
そのため、このpartialにいいねボタンを追加すれば、一覧ページにも詳細ページにも同じいいねボタンが表示されます。
create.turbo_stream.erbを作成する
次に、いいね作成時に返すTurbo Stream用のviewを作ります。
app/views/likes/create.turbo_stream.erb を作成します。
<!-- app/views/likes/create.turbo_stream.erb -->
<%= turbo_stream.replace dom_id(@post, :like_button) do %>
<%= render "likes/like_button", post: @post %>
<% end %>
ここでは、dom_id(@post, :like_button) の部分を置き換えています。
つまり、以下で作った更新対象を、
<%= turbo_frame_tag dom_id(post, :like_button) do %>
最新のいいねボタンpartialで差し替えています。
いいね作成後は、heart.svg から heart-active.svg に切り替わります。
destroy.turbo_stream.erbを作成する
いいね解除時も、同じようにTurbo Stream用のviewを作ります。
app/views/likes/destroy.turbo_stream.erb を作成します。
<!-- app/views/likes/destroy.turbo_stream.erb -->
<%= turbo_stream.replace dom_id(@post, :like_button) do %>
<%= render "likes/like_button", post: @post %>
<% end %>
解除時も、やっていることは作成時と同じです。
現在の状態に合わせて、いいねボタンpartialを描画し直しています。
いいね解除後は、heart-active.svg から heart.svg に戻ります。
createとdestroyで同じpartialを描画する理由
create.turbo_stream.erb と destroy.turbo_stream.erb は、ほとんど同じ内容です。
<%= turbo_stream.replace dom_id(@post, :like_button) do %>
<%= render "likes/like_button", post: @post %>
<% end %>
最初は、作成時と解除時で別々のHTMLを書きたくなります。
しかし、今回のように同じpartialを描画し直すと、状態に応じた表示切り替えをpartial側にまとめられます。
いいね済みかどうかは、以下で判定しています。
<% if current_user.likes.exists?(post_id: post.id) %>
そのため、Turbo Stream側では「今の状態でいいねボタンを描画し直す」だけで済みます。
簡単に見た目を整える
いいねボタンの見た目を整えます。
前回作成した app/assets/stylesheets/pages/_posts.scss に追記します。
// app/assets/stylesheets/pages/_posts.scss
.post-actions {
margin-top: 12px;
}
.like-button {
display: flex;
align-items: center;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
}
.like-button:hover {
opacity: 0.8;
}
like-button は、button_to の見た目を消して、ハート画像だけが見えるようにしています。
動作確認
サーバーを起動します。
# ターミナル
bin/dev
ログインした状態で、投稿一覧にアクセスします。
# ブラウザ
http://localhost:3000/posts
確認することは以下です。
- 投稿にハートボタンが表示される
- ハートを押すといいねできる
- もう一度押すといいねを解除できる
- ページ全体が再読み込みされず、ハート部分だけ切り替わる
- 投稿詳細ページでも同じようにいいねできる
- 同じユーザーが同じ投稿に複数回いいねできない
詳細ページでも確認します。
# ブラウザ
http://localhost:3000/posts/8
8 の部分は、自分の環境にある投稿IDに置き換えてください。
今回分かったこと
今回は、いいね機能とTurbo Streamでの非同期更新を作りました。
やったことを整理すると以下です。
-
Likeモデルを作成した -
likesテーブルにuser_idとpost_idを保存した -
add_index :likes, [:user_id, :post_id], unique: trueで重複いいねを防いだ -
UserとPostにhas_many :likesを追加した -
resource :likeをpostsにネストした -
LikesControllerでいいね作成・解除を実装した -
likes/_like_button.html.erbにいいねボタンを切り出した -
turbo_frame_tagで更新対象を作った -
turbo_stream.replaceでいいね部分だけ更新した
特に、Turbo Streamを使うことで、ページ全体を再読み込みせずに必要な部分だけ更新できることが分かりました。
また、いいね機能では「1ユーザー1投稿1いいね」を守るために、モデル側のバリデーションだけでなくDB側にもユニークインデックスを入れることが大事だと分かりました。
次回
次は、コメント機能とメンション通知メールを作ります。
投稿にコメントできるようにし、コメント本文にメンションが含まれている場合に通知メールを送る形にしていきます。
Discussion