📸

05. いいね機能とTurbo Streamでの非同期更新

に公開

はじめに

前回は、04. 投稿詳細・Xシェア・共通partialを作るで、投稿詳細ページとXシェア機能を作りました。

また、投稿一覧と投稿詳細で同じ表示を使えるように、投稿表示をpartialに切り出しました。

今回は、SNSらしい機能として「いいね機能」を作ります。

やることを整理すると以下です。

  • Likeモデルを作る
  • UserとPostにLikeの関連付けを追加する
  • 1ユーザーが1投稿に1回だけいいねできるようにする
  • いいね作成・解除用のルーティングを追加する
  • いいねボタンをpartialにする
  • Turbo Streamでいいね部分だけ非同期更新する

前回作った投稿partialに、いいねボタンを追加していきます。

ページ全体を再読み込みせず、ハート部分だけが切り替わる形を目指します。


いいね機能の全体像

今回作るいいね機能では、usersposts の間に likes テーブルを作ります。

イメージは以下です。

User
  └── likesを複数持つ

Post
  └── likesを複数持つ

Like
  ├── 1人のuserに属する
  └── 1つのpostに属する

likes テーブルには、以下の情報を保存します。

  • 誰がいいねしたか
  • どの投稿にいいねしたか

つまり、user_idpost_id を保存することで、いいねの状態を表します。


Likeモデルを作成する

まず、Likeモデルを作成します。

# ターミナル
bin/rails g model Like user:references post:references

このコマンドで、Like モデルと likes テーブル用のmigrationファイルが作成されます。

user:referencespost:references を指定しているため、user_idpost_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_idpost_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

参考: Railsガイド - Rails のルーティング


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])

postslike をネストしているため、投稿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にいいねボタンを追加すれば、一覧ページにも詳細ページにも同じいいねボタンが表示されます。

参考: Railsガイド - レイアウトとレンダリング


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.erbdestroy.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_idpost_id を保存した
  • add_index :likes, [:user_id, :post_id], unique: true で重複いいねを防いだ
  • UserPosthas_many :likes を追加した
  • resource :likeposts にネストした
  • LikesController でいいね作成・解除を実装した
  • likes/_like_button.html.erb にいいねボタンを切り出した
  • turbo_frame_tag で更新対象を作った
  • turbo_stream.replace でいいね部分だけ更新した

特に、Turbo Streamを使うことで、ページ全体を再読み込みせずに必要な部分だけ更新できることが分かりました。

また、いいね機能では「1ユーザー1投稿1いいね」を守るために、モデル側のバリデーションだけでなくDB側にもユニークインデックスを入れることが大事だと分かりました。


次回

次は、コメント機能とメンション通知メールを作ります。

投稿にコメントできるようにし、コメント本文にメンションが含まれている場合に通知メールを送る形にしていきます。

Discussion