ChallengesðŸ§Đ UI SystemsNested Comment Widget
👑ApexSystem DesignUI ComponentRecursive Rendering

Nested Comment Widget

Design a comment system with nested replies, like/dislike, edit/delete, lazy loading of deep threads — covering recursive rendering, optimistic updates, and moderation patterns.

Nested Comment Widget

Challenge: "Design a comment widget like Reddit or Facebook — support nested replies, edit/delete, reactions, and collapse threads."

Requirements

Functional

  • Add top-level comments
  • Reply to any comment (unlimited nesting)
  • Edit and delete own comments
  • Like/dislike reactions
  • Collapse/expand reply threads
  • Load more replies (pagination per thread)
  • Sort: Newest, Oldest, Most Liked

Non-Functional

  • Optimistic updates for instant feedback
  • Handle threads with 1000+ comments
  • Accessible nested tree navigation
  • Real-time new comment notifications

Data Model

interface Comment {
  id: string;
  parentId: string | null;
  content: string;
  author: { id: string; name: string; avatar: string };
  createdAt: string;
  updatedAt?: string;
  likes: number;
  dislikes: number;
  userVote: 'like' | 'dislike' | null;
  replyCount: number;
  isDeleted: boolean;
}
 
type CommentTree = Comment & { replies: CommentTree[] };

Flat vs Tree Storage

API returns flat list with parentId pointers (efficient for the server):

[
  { "id": "1", "parentId": null, "content": "Great article!" },
  { "id": "2", "parentId": "1", "content": "Thanks!" },
  { "id": "3", "parentId": "1", "content": "Agreed." },
  { "id": "4", "parentId": "2", "content": "No problem!" }
]

Client builds the tree:

function buildTree(comments: Comment[]): CommentTree[] {
  const map = new Map<string, CommentTree>();
  const roots: CommentTree[] = [];
 
  for (const comment of comments) {
    map.set(comment.id, { ...comment, replies: [] });
  }
 
  for (const comment of comments) {
    const node = map.get(comment.id)!;
    if (comment.parentId) {
      map.get(comment.parentId)?.replies.push(node);
    } else {
      roots.push(node);
    }
  }
 
  return roots;
}

Component Architecture

CommentSection
├── CommentForm (top-level)
├── SortControls
└── CommentList
    ├── CommentItem
    │   ├── Avatar + Author + Timestamp
    │   ├── Content (or [deleted])
    │   ├── ActionBar (Like, Dislike, Reply, Edit, Delete)
    │   ├── CommentForm (inline reply, shown on "Reply" click)
    │   └── CommentList (recursive — nested replies)
    │       ├── CommentItem
    │       │   └── ...
    │       └── "Load more replies" button
    └── "Load more comments" button

Recursive Comment Rendering

function CommentItem({
  comment,
  depth = 0,
  maxDepth = 8,
}: {
  comment: CommentTree;
  depth?: number;
  maxDepth?: number;
}) {
  const [isReplying, setIsReplying] = useState(false);
  const [isEditing, setIsEditing] = useState(false);
  const [collapsed, setCollapsed] = useState(false);
 
  return (
    <div
      className="comment"
      style={{ marginLeft: Math.min(depth * 24, maxDepth * 24) }}
      role="article"
      aria-label={`Comment by ${comment.author.name}`}
    >
      <div className="comment-header">
        <img src={comment.author.avatar} alt="" className="avatar" />
        <span className="author">{comment.author.name}</span>
        <time>{formatRelative(comment.createdAt)}</time>
        {comment.replies.length > 0 && (
          <button
            onClick={() => setCollapsed(!collapsed)}
            aria-expanded={!collapsed}
          >
            {collapsed ? `â–ķ ${comment.replies.length} replies` : '▾'}
          </button>
        )}
      </div>
 
      {comment.isDeleted ? (
        <p className="deleted">[Comment deleted]</p>
      ) : isEditing ? (
        <EditForm comment={comment} onCancel={() => setIsEditing(false)} />
      ) : (
        <p className="content">{comment.content}</p>
      )}
 
      <ActionBar
        comment={comment}
        onReply={() => setIsReplying(true)}
        onEdit={() => setIsEditing(true)}
      />
 
      {isReplying && (
        <CommentForm
          parentId={comment.id}
          onSubmit={() => setIsReplying(false)}
          onCancel={() => setIsReplying(false)}
          autoFocus
        />
      )}
 
      {!collapsed && comment.replies.length > 0 && (
        <div className="replies" role="list">
          {comment.replies.map(reply => (
            <CommentItem
              key={reply.id}
              comment={reply}
              depth={depth + 1}
              maxDepth={maxDepth}
            />
          ))}
        </div>
      )}
    </div>
  );
}

Key Design Decisions

1. Pagination Strategy

Top-level: cursor-based pagination (load more at bottom) Nested replies: fetch on expand (don't load all nested replies upfront)

async function loadReplies(parentId: string, cursor?: string) {
  const res = await fetch(
    `/api/comments/${parentId}/replies?cursor=${cursor}&limit=10`
  );
  return res.json();
}

2. Optimistic Updates

function addComment(content: string, parentId: string | null) {
  const tempId = `temp-${Date.now()}`;
  const optimistic: Comment = {
    id: tempId,
    parentId,
    content,
    author: currentUser,
    createdAt: new Date().toISOString(),
    likes: 0,
    dislikes: 0,
    userVote: null,
    replyCount: 0,
    isDeleted: false,
  };
 
  dispatch({ type: 'ADD_OPTIMISTIC', comment: optimistic });
 
  api.createComment({ content, parentId })
    .then(real => dispatch({ type: 'REPLACE_TEMP', tempId, comment: real }))
    .catch(() => dispatch({ type: 'REMOVE_TEMP', tempId }));
}

3. Deep Nesting Handling

After maxDepth levels, stop indenting and show a "Continue thread →" link that opens a focused view of that sub-thread.

4. Soft Delete

Deleted comments with replies are marked isDeleted: true (content hidden) but kept in the tree to preserve thread structure. Leaf deletions remove entirely.

Performance Considerations

  • Virtualization: For 1000+ comments, virtualize the visible list
  • Memo: React.memo on CommentItem — most re-renders only affect one comment
  • Batch reactions: Debounce like/dislike API calls
  • Lazy avatar loading: loading="lazy" on avatar images below fold