はじめに
Ruby on Railsで掲示板アプリなどのコメント機能を作っているとき、「コメントに対する返信(リプライ)機能」を実装したいと思い、自己結合を使って実装しました。
実装する中で、フォームの挙動で少しハマったポイントがあったため、解決策や実装の流れを備忘録として残します。
※(掲示板・コメント機能は事前に実装している前提で本記事は進めてります。)
🎨 完成形のイメージ
この記事で最終的に実装する、返信機能付きのコメント・掲示板画面です。
1. 実装前の状態
2. 実装後の状態
🛠 使用技術・環境
- Ruby: 3.4.10
- Ruby on Rails: 8.1.4
- データベース: PostgreSQL
- CSS: Tailwind CSS
💻 実装ステップ:データベースの準備
1. データベース設計(自己結合)
通常のコメント機能であれば、どの掲示板に対するものか(board_id)と誰が書いたか(user_id)だけで成り立ちます。
しかし、「コメントに対する返信(リプライ)機能」を実装するには、「どのコメントに対する返信なのか」という親の情報を保持する必要があります。
これを解決するのが、同じテーブル同士を紐付ける「自己結合」という手法です。
具体的には、comments テーブルに親のIDを保存するための parent_id カラムを追加します。
マイグレーションファイルの作成
ターミナルで以下のコマンドを実行します。
bin/rails g migration AddParentIdToComments parent_id:bigint
生成されたファイルを次のように編集します。
class AddParentIdToComments < ActiveRecord::Migration[8.1]
def change
add_column :comments, :parent_id, :bigint
end
end
ファイルを作成・保存したら、以下のコマンドでデータベースに反映させます。
bin/rails db:migrate
2.Model(アソシエーションの設定)
app/models/comment.rb を開き、自分自身と紐づく設定(自己結合アソシエーション)を書きます。
class Comment < ApplicationRecord
belongs_to :board
belongs_to :user
# 親コメントを持つ (optional: true は「通常のコメントは親がいない(nil許容)」にするため)
belongs_to :parent, class_name: "Comment", optional: true
# 複数の返信 (子コメント) を持つ
has_many :replies, class_name: "Comment", foreign_key: "parent_id", dependent: :destroy
end
💡「class_name」って何?
Railsでは通常、belongs_to :parent と書くと parent_id から Parent モデルを探しに行こうとします。しかし、今回は「同じ Comment モデル同士」を紐付けたいため、いくつかの特別なオプションを指定しています。
-
class_name: "Comment"- 「紐づく相手は別のモデルではなく、同じ
Commentモデルですよ」と明示的に教えるための設定です。
- 「紐づく相手は別のモデルではなく、同じ
-
optional: true- 通常のコメント(一番最初の投稿)には親が存在しないため、
parent_idがnil(空)であることを許可するために指定しています(これがないと親がいないコメントを保存するときにバリデーションエラーになります)。
- 通常のコメント(一番最初の投稿)には親が存在しないため、
-
foreign_key: "parent_id"- 「親を探すときの外部キーとして、
board_idやuser_idではなくparent_idカラムを使ってね」と指定しています。
- 「親を探すときの外部キーとして、
これらを設定することで、comment.replies のように書くだけで、そのコメントに対する返信一覧を簡単に取得できるようになります!
3.Controller(コントローラーの修正)
返信機能を追加すると、通常のコメント投稿(掲示板への直接のコメント)と、コメントに対する返信(リプライ)の両方を処理する必要があります。
CommentsController を開き、ストロングパラメータと保存処理を修正します。
class CommentsController < ApplicationController
def create
comment = current_user.comments.new(comment_params)
if comment.save
redirect_to board_path(comment.board), success: "コメントの投稿に成功しました", status: :see_other
else
redirect_to board_path(comment.board), danger: "コメントの投稿に失敗しました", status: :see_other
end
end
private
def comment_params
# :parent_id をストロングパラメータに追加して受け取れるようにする
params.require(:comment).permit(:body, :parent_id).merge(board_id: params[:board_id])
end
end
4.コメント・返信 View の実装
コメント本体、返信一覧、返信用のフォームを1つのパーシャル(_comment.html.erb)にまとめます。
app/views/comments/_comment.html.erb
<div>
<!-- 投稿者名と本文 -->
<div>
<strong><%= comment.user.user_name %></strong>:
<p><%= comment.body %></p>
</div>
<!-- リプライ(返信)一覧 -->
<% if comment.replies.any? %>
<div>
<% comment.replies.each do |reply| %>
<div>
<span>↪︎</span>
<strong><%= reply.user.user_name %></strong>:
<span><%= reply.body %></span>
</div>
<% end %>
</div>
<% end %>
<!-- 返信フォーム -->
<div>
<% reply = comment.replies.new %>
<%= form_with model: reply, url: board_comments_path(comment.board) do |f| %>
<!-- 親のIDを隠しフィールドで送る(超重要) -->
<%= f.hidden_field :parent_id, value: comment.id %>
<div>
<%= f.text_area :body, placeholder: "返信を入力..." %>
</div>
<div>
<%= f.submit "返信する" %>
</div>
<% end %>
</div>
</div>
comment.replies.any?
-
comment.repliesで、その親コメントに紐づく返信(子コメント)をすべて取得しています。 -
any?を使って、**「返信が1件以上ある場合のみ、リストを表示する」**という条件分岐を行っています。これにより、返信がないコメントの下に変な余白や空のリストが表示されるのを防いでいます。
<% reply = comment.replies.new %>
-
form_withに渡すための「空の新規コメント(返信用のインスタンス)」を作っています。 - 普通に
Comment.newと書く代わりに、親コメントの関連付け(comment.replies.new)から生成することで、自動的に親のIDが紐づいた状態のインスタンスを用意することができます。
f.hidden_field :parent_id
- ここが一番重要なポイントです!
- どのコメントに対する返信なのかをサーバー(コントローラー)に伝えるため、
hidden_field(隠し入力)を使って親コメントのcomment.idを一緒に送信しています。 - これがないと、サーバー側は「どこにも紐づかない大元の新規コメント」と判断してしまい、親コメントとして保存されてしまいます。
⚠️ ハマりポイント:リプライ(返信)が親コメントとしても二重表示される?
ネスト構造の返信機能を実装した際、「返信コメントが、親コメントの配下だけでなく親コメントのリストにも表示されてしまう」という現象が発生しました。
なぜ起きるのか?
board.comments をそのまま全件取得して View でループさせると、parent_id の有無に関わらずデータベース上のすべてのコメントが親コメント一覧として描画されてしまいます。
その結果、親コメントの中に「返信」が表示されているにもかかわらず、その下にもう一度独立したコメントとして同じ返信が表示されてしまいます。
💡 解決策:where(parent_id: nil) で親コメントだけを絞り込む
大元のコメント一覧(親コメントのリスト)を表示する際は、parent_id が nil のデータ(=どのコメントへの返信でもない大元の投稿)だけを抽出します。
下記いずれかの方法で対応できますが、今回はビュー側で実装しております。
コントローラー側で絞り込む場合:
N+1問題を回避するために includes も組み合わせて以下のように読み込みます。
# app/controllers/boards_controller.erb
def show
@board = Board.find(params[:id])
# parent_id が nil の「親コメント」だけを取得
@comments = @board.comments.where(parent_id: nil).includes(:user, replies: :user)
end
ビュー側でのループ:
# app/views/_board.html.erb
<%# コメント一覧表示 %>
<%= render board.comments.where(parent_id: nil) %>




