Threaded comments enable structured discussions. Here is how to build a full-featured commenting system.
Types and Data
// types/comments.ts
export interface Comment {
id: string;
parentId: string | null;
authorId: string;
authorName: string;
authorAvatar?: string;
content: string;
createdAt: string;
updatedAt?: string;
votes: number;
userVote: 1 | -1 | 0;
children: Comment[];
deleted: boolean;
}
useComments Hook
// hooks/useComments.ts
"use client";
import { useCallback, useOptimistic, useState, useTransition } from "react";
import type { Comment } from "@/types/comments";
interface UseCommentsOptions {
postId: string;
initialComments: Comment[];
}
export function useComments({ postId, initialComments }: UseCommentsOptions) {
const [comments, setComments] = useState(initialComments);
const [, startTransition] = useTransition();
const addComment = useCallback(
async (content: string, parentId: string | null = null) => {
const tempId = `temp-${Date.now()}`;
const optimistic: Comment = {
id: tempId,
parentId,
authorId: "current-user",
authorName: "You",
content,
createdAt: new Date().toISOString(),
votes: 0,
userVote: 0,
children: [],
deleted: false,
};
// Optimistic add
setComments((prev) => insertComment(prev, optimistic, parentId));
// Server call
const res = await fetch(`/api/posts/${postId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, parentId }),
});
if (res.ok) {
const saved = await res.json();
setComments((prev) => replaceComment(prev, tempId, saved));
} else {
// Rollback
setComments((prev) => removeComment(prev, tempId));
}
},
[postId]
);
const editComment = useCallback(
async (commentId: string, content: string) => {
setComments((prev) => updateCommentContent(prev, commentId, content));
const res = await fetch(`/api/posts/${postId}/comments/${commentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
// Refetch on failure
const fresh = await fetch(`/api/posts/${postId}/comments`).then((r) => r.json());
setComments(fresh);
}
},
[postId]
);
const deleteComment = useCallback(
async (commentId: string) => {
setComments((prev) => markDeleted(prev, commentId));
await fetch(`/api/posts/${postId}/comments/${commentId}`, {
method: "DELETE",
});
},
[postId]
);
const vote = useCallback(
async (commentId: string, direction: 1 | -1) => {
setComments((prev) => applyVote(prev, commentId, direction));
await fetch(`/api/posts/${postId}/comments/${commentId}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ direction }),
});
},
[postId]
);
return { comments, addComment, editComment, deleteComment, vote };
}
// Tree manipulation helpers
function insertComment(tree: Comment[], comment: Comment, parentId: string | null): Comment[] {
if (!parentId) return [...tree, comment];
return tree.map((c) => ({
...c,
children: c.id === parentId
? [...c.children, comment]
: insertComment(c.children, comment, parentId),
}));
}
function replaceComment(tree: Comment[], oldId: string, newComment: Comment): Comment[] {
return tree.map((c) =>
c.id === oldId
? { ...newComment, children: c.children }
: { ...c, children: replaceComment(c.children, oldId, newComment) }
);
}
function removeComment(tree: Comment[], id: string): Comment[] {
return tree
.filter((c) => c.id !== id)
.map((c) => ({ ...c, children: removeComment(c.children, id) }));
}
function updateCommentContent(tree: Comment[], id: string, content: string): Comment[] {
return tree.map((c) =>
c.id === id
? { ...c, content, updatedAt: new Date().toISOString() }
: { ...c, children: updateCommentContent(c.children, id, content) }
);
}
function markDeleted(tree: Comment[], id: string): Comment[] {
return tree.map((c) =>
c.id === id
? { ...c, deleted: true, content: "[deleted]" }
: { ...c, children: markDeleted(c.children, id) }
);
}
function applyVote(tree: Comment[], id: string, direction: 1 | -1): Comment[] {
return tree.map((c) => {
if (c.id === id) {
const voteDelta = c.userVote === direction ? -direction : direction - c.userVote;
return {
...c,
votes: c.votes + voteDelta,
userVote: c.userVote === direction ? 0 : direction,
};
}
return { ...c, children: applyVote(c.children, id, direction) };
});
}
Recursive Comment Component
"use client";
import { useState } from "react";
import type { Comment } from "@/types/comments";
interface CommentNodeProps {
comment: Comment;
depth: number;
onReply: (content: string, parentId: string) => void;
onEdit: (id: string, content: string) => void;
onDelete: (id: string) => void;
onVote: (id: string, direction: 1 | -1) => void;
maxDepth?: number;
}
function CommentNode({
comment,
depth,
onReply,
onEdit,
onDelete,
onVote,
maxDepth = 5,
}: CommentNodeProps) {
const [replying, setReplying] = useState(false);
const [editing, setEditing] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const isOwnComment = comment.authorId === "current-user";
if (comment.deleted && comment.children.length === 0) return null;
return (
<div className={`${depth > 0 ? "ml-6 border-l-2 border-muted pl-4" : ""}`}>
<div className="py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{comment.authorAvatar && (
<img src={comment.authorAvatar} alt="" className="w-5 h-5 rounded-full" />
)}
<span className="font-medium text-foreground">{comment.authorName}</span>
<span>{timeAgo(comment.createdAt)}</span>
{comment.updatedAt && <span>(edited)</span>}
<button
onClick={() => setCollapsed(!collapsed)}
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
>
{collapsed ? "[+]" : "[-]"}
</button>
</div>
{!collapsed && (
<>
{editing ? (
<CommentEditor
initialValue={comment.content}
onSubmit={(content) => {
onEdit(comment.id, content);
setEditing(false);
}}
onCancel={() => setEditing(false)}
/>
) : (
<p className="mt-1 text-sm">{comment.content}</p>
)}
<div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-1 text-xs">
<button
onClick={() => onVote(comment.id, 1)}
className={comment.userVote === 1 ? "text-primary font-bold" : "text-muted-foreground"}
>
β²
</button>
<span className={`font-medium ${comment.votes > 0 ? "text-primary" : comment.votes < 0 ? "text-red-500" : ""}`}>
{comment.votes}
</span>
<button
onClick={() => onVote(comment.id, -1)}
className={comment.userVote === -1 ? "text-red-500 font-bold" : "text-muted-foreground"}
>
βΌ
</button>
</div>
{depth < maxDepth && (
<button
onClick={() => setReplying(!replying)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Reply
</button>
)}
{isOwnComment && !comment.deleted && (
<>
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground">
Edit
</button>
<button onClick={() => onDelete(comment.id)} className="text-xs text-red-500">
Delete
</button>
</>
)}
</div>
{replying && (
<div className="mt-2">
<CommentEditor
onSubmit={(content) => {
onReply(content, comment.id);
setReplying(false);
}}
onCancel={() => setReplying(false)}
placeholder="Write a reply..."
/>
</div>
)}
{comment.children.map((child) => (
<CommentNode
key={child.id}
comment={child}
depth={depth + 1}
onReply={onReply}
onEdit={onEdit}
onDelete={onDelete}
onVote={onVote}
maxDepth={maxDepth}
/>
))}
</>
)}
</div>
</div>
);
}
function CommentEditor({
initialValue = "",
onSubmit,
onCancel,
placeholder = "Write a comment...",
}: {
initialValue?: string;
onSubmit: (content: string) => void;
onCancel?: () => void;
placeholder?: string;
}) {
const [value, setValue] = useState(initialValue);
return (
<div className="space-y-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="w-full border rounded p-2 text-sm min-h-[60px]"
/>
<div className="flex gap-2">
<button
onClick={() => {
if (value.trim()) onSubmit(value.trim());
}}
disabled={!value.trim()}
className="text-xs bg-primary text-primary-foreground px-3 py-1 rounded disabled:opacity-50"
>
Submit
</button>
{onCancel && (
<button onClick={onCancel} className="text-xs text-muted-foreground">
Cancel
</button>
)}
</div>
</div>
);
}
function timeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
Main Comments Section
import { useComments } from "@/hooks/useComments";
export function CommentsSection({ postId, initialComments }: {
postId: string;
initialComments: Comment[];
}) {
const { comments, addComment, editComment, deleteComment, vote } = useComments({
postId,
initialComments,
});
return (
<section className="mt-12">
<h2 className="text-xl font-bold mb-4">Comments ({countAll(comments)})</h2>
<CommentEditor
onSubmit={(content) => addComment(content)}
placeholder="Add a comment..."
/>
<div className="mt-6">
{comments.map((comment) => (
<CommentNode
key={comment.id}
comment={comment}
depth={0}
onReply={(content, parentId) => addComment(content, parentId)}
onEdit={editComment}
onDelete={deleteComment}
onVote={vote}
/>
))}
</div>
</section>
);
}
function countAll(comments: Comment[]): number {
return comments.reduce((sum, c) => sum + 1 + countAll(c.children), 0);
}
Need a Custom Community Feature?
We build commenting systems, forums, and community features for web applications. Contact us to discuss your project.