Skip to main content
Back to Blog
Tutorials
4 min read
January 1, 2025

How to Build a Comments System with Nested Threads in React

Build a threaded comments system in React with nested replies, voting, editing, optimistic updates, and real-time additions.

Ryel Banfield

Founder & Lead Developer

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.

commentsnested threadsrecursive componentsReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles