コメントとディスカッション
ルール
- スレッド型コメント: commentsテーブルの外部キー
parent_id — トップレベルは null、返信は親を参照
- 楽観的UI: コメントを即座にUIに追加、サーバー応答で確定、エラー時はロールバック
- @メンション: コメント本文内の
@ユーザー名 を解析 — @ キー入力時にユーザー検索ドロップダウンを表示、メンションされたユーザーに通知
- リアクション: 独立した
reactions テーブル (commentId, userId, emoji) — (commentId, userId, emoji) に一意制約
- モデレーション:
status フィールド (published, flagged, removed) — フラグ付きコンテンツのモデレーションキュー、ワードリストによる自動フラグ
- ソフトデリート:
deletedAt タイムスタンプを設定 — 返信があるコメントは「削除済み」プレースホルダーを表示、孤立した削除コメントは完全非表示
- カーソルページネーション:
cursor (commentId) + limit — createdAt でソート、スクロールで古いコメントを読み込み
- 編集履歴: 編集内容を別テーブルまたはJSON配列に保存 — 最終編集タイムスタンプと共に「編集済み」インジケーターを表示
パターン
const comments = await db.query.comments.findMany({
where: and(eq(comments.postId, postId), isNull(comments.parentId)),
with: {
author: { columns: { id: true, name: true, avatar: true } },
replies: {
with: { author: { columns: { id: true, name: true, avatar: true } } },
orderBy: [asc(comments.createdAt)],
},
reactions: true,
},
orderBy: [desc(comments.createdAt)],
limit: 20,
});
const addComment = useMutation({
mutationFn: (body: string) => api.post(`/posts/${postId}/comments`, { body }),
onMutate: async (body) => {
await queryClient.cancelQueries({ queryKey: ["comments", postId] });
const previous = queryClient.getQueryData(["comments", postId]);
queryClient.setQueryData(["comments", postId], (old: Comment[]) => [
{ id: `temp-${Date.now()}`, body, author: currentUser, createdAt: new Date() },
...old,
]);
return { previous };
},
onError: (_err, _body, context) => {
queryClient.setQueryData(["comments", postId], context?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["comments", postId] }),
});
避けるべきパターン
- 無制限のネスト深度 — 2〜3レベルで制限し、それ以上深い返信はフラット化する
- 全コメントを一度に読み込む — カーソルページネーションを使用する(特に活発なディスカッションの場合)
- クライアント側のみでのメンション解析 — サーバー側でメンションを検証・抽出し、通知を送信する
- 返信のあるコメントのハードデリート — スレッドが孤立するため、ソフトデリートしてプレースホルダーを表示する
- 一意制約のないリアクション — 同じユーザーによる重複リアクションが許可される