AlgoInsight

Free lesson · Frontend System Design

Reddit-Style Nested Comments - Full LLD Case Study

Hard16 min readfrontendsystem-design

What we're building and why it's special

We're building a nested (threaded) comment system like Reddit's: comments that can have replies, which can have replies, to arbitrary depth - rendered as an indented tree, with each thread collapsible and a reply form that can attach a new comment to any node. This is the canonical recursive component problem, and it's special because it forces you to confront things the flat-list problems didn't: a recursive component that renders itself, a tree data model and the decision of how to shape it, the awkwardness of immutably updating a deeply nested structure, and the subtle question of where state lives in a self-referential hierarchy - which presentational state is node-local and which data must live at the root. Get nested comments right and you've demonstrated recursion, tree manipulation, and state-placement judgment all at once, which is why interviewers reach for it.

The defining idea, which everything below orbits, is recursion: a Comment component renders its replies, and each reply is itself a Comment component - so the component is self-referential, and a single component definition renders a tree of any depth. That's elegant, but it raises real design questions. Where does the collapse state for each node live? When someone replies to a comment buried five levels deep, how do you update the data without mutating it, and where does that update happen? How do you keep arbitrary depth from indenting off the screen or blowing up rendering? This case study answers each, in code.

Requirements (the clarify phase)

Functional. Render a tree of comments - each with an author, text, and replies - indented by depth. Any comment with replies can be collapsed/expanded. The user can reply to any comment, which adds a child to that node. Show a reply count on collapsed threads.

Non-functional. Support arbitrary depth gracefully - meaning a depth cap so deep threads don't indent off-screen (Reddit shows a "continue this thread" link past a point). Stay performant on large trees (so collapsing one node doesn't re-render the whole tree - memoization). Be accessible (tree semantics, aria-expanded, keyboard). And shape the data model for efficient updates, since adding a reply deep in a nested tree is the operation that exposes a poor model.

Out of scope for the core (mention, defer): real-time updates as others comment (a later case study), full pagination of huge comment sets (a "load more replies" stub suffices), a rich-text editor, voting/sorting backends, and edit/delete (which use the same update machinery as reply).

The data model - the decision that shapes everything

Before components, decide how the comment tree is stored, because it determines how hard updates are. Two models, and articulating the tradeoff is a strong senior signal.

Model A - the nested tree. Each comment carries its replies inline: { id, author, text, replies: Comment[] }. The tree is the data structure.

const comments = [
  { id: 'c1', author: 'alice', text: 'Top comment', replies: [
    { id: 'c2', author: 'bob', text: 'A reply', replies: [
      { id: 'c3', author: 'carol', text: 'A deep reply', replies: [] },
    ]},
    { id: 'c4', author: 'dave', text: 'Another reply', replies: [] },
  ]},
];

This model is natural - it mirrors the UI exactly, so rendering is a trivial recursion. Its weakness is updates: to add a reply to c3 (deep in the tree), you can't mutate c3.replies (that breaks immutability and React's change detection); you must produce a new tree with the entire path from the root to c3 rebuilt immutably - an O(depth·breadth) recursive walk. For moderate, read-heavy trees that's fine; for large or write-heavy ones it's awkward and slow.

Model B - the normalized map. Store all comments in a flat lookup keyed by id, where each comment references its children by id: { [id]: { id, author, text, childIds: string[] } }, plus a list of root ids.

const byId = {
  c1: { id: 'c1', author: 'alice', text: 'Top comment',  childIds: ['c2', 'c4'] },
  c2: { id: 'c2', author: 'bob',   text: 'A reply',       childIds: ['c3'] },
  c3: { id: 'c3', author: 'carol', text: 'A deep reply',  childIds: [] },
  c4: { id: 'c4', author: 'dave',  text: 'Another reply', childIds: [] },
};
const rootIds = ['c1'];

This model makes updates O(1): to add a reply to c3, you touch only the new comment and c3's childIds - no tree walk. Lookups by id are O(1) too (handy for edit/delete/vote). Its cost is that rendering must reconstruct the tree by following childIds recursively, and the data is less intuitive. Normalization is the standard answer for large, frequently-updated trees - and indeed, normalizing nested data is a general state-management best practice for exactly this reason.

The decision: nested tree for simplicity on moderate threads; normalized map for large or write-heavy comment systems, because the normalized model makes deep updates O(1) and lookups trivial. For this case study I'll implement the nested model (it's the clearest vehicle for the recursive-component teaching), but show the update function for both so you can speak to the tradeoff - and recommend normalization when the interviewer pushes on scale.

Component architecture - the recursion

The decomposition centers on one recursive component:

graph TD
    CT["CommentTree (root container)<br/>owns: comment data (useComments)<br/>provides: onReply handler"]
    CT -->|"comment, depth=0, onReply"| CN1["CommentNode (recursive)<br/>local state: collapsed, replying"]
    CN1 --> CB1[CommentBody]
    CN1 --> CA1[CommentActions]
    CN1 --> RF1["ReplyForm (when replying)"]
    CN1 -->|"for each reply: comment, depth+1, onReply"| CN2["CommentNode (itself!)"]
    CN2 -->|"for each reply: depth+2, onReply"| CN3["CommentNode (itself!) …"]
    Note["onReply is threaded UNCHANGED through every level<br/>→ a reply at any depth calls the SAME root handler"] -.-> CN1
    Depth["depth increments each level → indentation + cap"] -.-> CN2
This diagram shows the recursive component architecture for nested comments. The CommentTree root container owns the comment data through a useComments hook and provides an onReply handler. It renders each top-level comment as a CommentNode, passing the comment, a depth of zero, and the onReply handler. CommentNode is the recursive component: it holds its own local state for whether it's collapsed and whether its reply form is open, renders a CommentBody, CommentActions, and a ReplyForm when replying, and then - crucially - renders each of its replies as another CommentNode, passing depth plus one and the same onReply. Those CommentNodes render their replies as CommentNodes in turn, producing arbitrary depth from one definition. Two notes: the onReply handler is threaded unchanged through every level, so a reply at any depth calls the same root handler; and the depth increments each level, driving indentation and a depth cap."

The communication pattern has a recursion-specific twist worth naming: the onReply callback is threaded down unchanged through every level of the recursion. Each CommentNode receives onReply and passes the same onReply to its child CommentNodes. So when a user replies to a comment five levels deep, that node calls onReply(itsId, text) - and because the callback is the same function all the way down, it invokes the single handler at the root (CommentTree's addReply), which owns the data and applies the update. Data flows down the recursion (each node gets its comment); events flow up - but via a callback that short-circuits straight to the root rather than bubbling level by level. This is the key insight for stateful recursive components: thread the mutation callbacks through to the root, where the data lives.

State design - what lives where in a recursive tree

This is the subtle, high-signal part. There are three pieces of state, and they live at different levels for principled reasons:

The principle, which is the state-placement lesson applied to recursion: presentational, node-specific state (collapse, reply-open) stays local in each recursive node; the shared data that mutations touch lives at the root, reached via callbacks threaded through the recursion. Stating this division - "collapse is local node state, but the comment data is the source of truth at the root because replies mutate the whole tree" - is exactly the senior judgment the problem is designed to surface.

How a reply flows through the recursion

Trace replying to a deep comment, because the data flow is the design:

sequenceDiagram
    participant U as User
    participant CN as CommentNode (depth 5)
    participant RF as ReplyForm
    participant CT as CommentTree (root)
    participant H as useComments.addReply
    participant Tree as Comment data

    U->>CN: clicks "Reply"
    CN->>CN: setReplying(true) - local state
    U->>RF: types + submits "nice point"
    RF->>CN: onSubmit("nice point")
    CN->>CT: onReply(comment.id, "nice point")
    Note over CN,CT: onReply was threaded unchanged through all 5 levels → calls the ROOT handler
    CT->>H: addReply(parentId, text)
    H->>Tree: rebuild tree immutably (new node under parentId)
    Tree-->>CT: new comments tree
    CT-->>CN: re-render with the new reply present
    CN->>CN: setReplying(false) - close the form
This sequence diagram traces replying to a comment five levels deep. The user clicks Reply on a deep CommentNode, which sets its local replying state to true, showing the ReplyForm. The user types and submits, and the ReplyForm calls onSubmit up to its CommentNode. The CommentNode calls onReply with its own comment id and the text. The key point, noted in the diagram, is that onReply was threaded unchanged through all five levels of recursion, so this call goes straight to the root handler rather than bubbling level by level. The CommentTree root calls addReply in the useComments hook, which rebuilds the tree immutably with the new node placed under the parent id, producing a new comments tree. The root re-renders with the new reply present, and the CommentNode sets its local replying state back to false to close the form."

Implementation

The data layer - the immutable nested update

The crux of the nested model is updating immutably. addReplyNested recursively walks the tree, rebuilding only the path to the target parent and leaving the rest untouched (so React's reference equality lets unaffected subtrees skip re-rendering):

function addReplyNested(comments, parentId, reply) {
  return comments.map((comment) => {
    if (comment.id === parentId) {
      // found the parent: append the reply (new array, new object)
      return { ...comment, replies: [...comment.replies, reply] };
    }
    if (comment.replies && comment.replies.length > 0) {
      // not the parent, but it has replies: recurse, rebuild only if changed
      return { ...comment, replies: addReplyNested(comment.replies, parentId, reply) };
    }
    return comment; // leaf, not the parent: unchanged (same reference)
  });
}

This is the awkwardness the data-model section warned about: a single deep insert rebuilds the whole root-to-target path. Contrast the normalized update, which is flat and O(1) - touch only the new node and the parent's childIds:

function addReplyNormalized(byId, parentId, reply) {
  return {
    ...byId,
    [reply.id]: { ...reply, childIds: [] },
    [parentId]: { ...byId[parentId], childIds: [...byId[parentId].childIds, reply.id] },
  };
}

Showing both - and saying "the nested update is an O(depth) path rebuild, which is why I'd normalize for a large comment system" - is the senior framing. The useComments hook wraps the nested update and generates the optimistic new comment:

import { useState, useCallback } from 'react';

function useComments(initial) {
  const [comments, setComments] = useState(initial);

  const addReply = useCallback((parentId, text) => {
    const reply = {
      id: crypto.randomUUID(),
      author: 'me',
      text,
      replies: [],
      createdAt: Date.now(),
    };
    // optimistic local insert; in production also POST and reconcile/rollback
    setComments((prev) => addReplyNested(prev, parentId, reply));
  }, []);

  return { comments, addReply };
}

(In production addReply would also POST to the server and, on failure, roll back - the optimistic-update pattern from the loading-states lesson. The local insert is what makes the reply appear instantly.)

The root container

CommentTree owns the data and renders the top-level nodes, passing depth=0 and the threaded onReply:

function CommentTree({ initialComments }) {
  const { comments, addReply } = useComments(initialComments);

  if (comments.length === 0) {
    return <p className="comments__empty">No comments yet. Be the first to reply.</p>;
  }

  return (
    <ul className="comment-tree" role="tree" aria-label="Comments">
      {comments.map((comment) => (
        <CommentNode
          key={comment.id}
          comment={comment}
          depth={0}
          onReply={addReply}   // ← the same handler every node will receive
        />
      ))}
    </ul>
  );
}

The recursive component

CommentNode is the heart. It holds its local collapse and reply-open state, renders its body/actions/optional form, and - the recursion - renders a CommentNode for each reply, threading onReply down and incrementing depth. A depth cap replaces deep recursion with a "continue thread" link, and React.memo prevents a node's local state change from re-rendering siblings:

import { useState, memo } from 'react';

const MAX_DEPTH = 6;
const INDENT_PX = 16;

const CommentNode = memo(function CommentNode({ comment, depth, onReply }) {
  const [collapsed, setCollapsed] = useState(false);   // LOCAL presentational state
  const [replying, setReplying]   = useState(false);   // LOCAL presentational state

  const hasReplies = comment.replies && comment.replies.length > 0;

  function handleReplySubmit(text) {
    onReply(comment.id, text);   // ← bubbles straight to the ROOT handler
    setReplying(false);
  }

  return (
    <li
      className="comment"
      role="treeitem"
      aria-expanded={hasReplies ? !collapsed : undefined}
    >
      <div
        className="comment__row"
        style={{ marginInlineStart: Math.min(depth, MAX_DEPTH) * INDENT_PX }}
      >
        {hasReplies && (
          <button
            className="comment__toggle"
            onClick={() => setCollapsed((c) => !c)}
            aria-label={collapsed ? 'Expand thread' : 'Collapse thread'}
          >
            {collapsed ? `[+] ${countReplies(comment)}` : '[−]'}
          </button>
        )}
        <CommentBody comment={comment} />
        <CommentActions onReply={() => setReplying((r) => !r)} />
      </div>

      {replying && (
        <ReplyForm onSubmit={handleReplySubmit} onCancel={() => setReplying(false)} />
      )}

      {!collapsed && hasReplies && (
        depth >= MAX_DEPTH ? (
          // depth cap: stop recursing, link out instead of indenting off-screen
          <a className="comment__continue" href={`/thread/${comment.id}`}>
            Continue this thread →
          </a>
        ) : (
          <ul className="comment__replies" role="group">
            {comment.replies.map((child) => (
              <CommentNode
                key={child.id}
                comment={child}
                depth={depth + 1}     // ← depth grows
                onReply={onReply}     // ← same handler threaded down
              />
            ))}
          </ul>
        )
      )}
    </li>
  );
});

Several decisions to narrate here. The recursion is the comment.replies.map(... <CommentNode/>) - the component renders itself for each child, which is what yields arbitrary depth. The depth cap (depth >= MAX_DEPTH) prevents both runaway indentation (which would push deep comments off-screen) and unbounded recursion/render cost, replacing further nesting with a "continue this thread" link exactly as Reddit does. The memo wrapper is a real performance decision: without it, toggling one node's collapse (local state) would re-render its parent and re-evaluate the whole subtree; with it, a node only re-renders when its props change, so collapse is cheap even in a large tree. And the collapse logic simply doesn't render the replies when collapsed - folding the subtree away entirely.

The presentational pieces

function CommentBody({ comment }) {
  return (
    <div className="comment__body">
      <span className="comment__author">{comment.author}</span>
      <p className="comment__text">{comment.text}</p>
    </div>
  );
}

function CommentActions({ onReply }) {
  return (
    <div className="comment__actions">
      <button type="button" onClick={onReply}>Reply</button>
      {/* upvote, share, etc. would emit their own callbacks up */}
    </div>
  );
}

function ReplyForm({ onSubmit, onCancel }) {
  const [text, setText] = useState('');
  return (
    <div className="reply-form">
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        aria-label="Write a reply"
        autoFocus
      />
      <div className="reply-form__actions">
        <button type="button" disabled={!text.trim()} onClick={() => onSubmit(text.trim())}>
          Reply
        </button>
        <button type="button" onClick={onCancel}>Cancel</button>
      </div>
    </div>
  );
}

// counts all descendants - itself a small recursion
function countReplies(comment) {
  if (!comment.replies || comment.replies.length === 0) return 0;
  return comment.replies.reduce((sum, child) => sum + 1 + countReplies(child), 0);
}

Note countReplies is itself a recursion over the tree - a nice illustration that once your data is a tree, both rendering and aggregation are naturally recursive.

Performance, accessibility, and edge cases

Performance. The big lever is React.memo on CommentNode so a local state change (collapse, reply-open) on one node doesn't re-render the rest of the tree - essential for large threads. The depth cap bounds recursion. For very large comment sets, you'd add "load more replies" pagination per node (don't render thousands of children at once) and could consider virtualization, though virtualizing a tree is genuinely hard (variable heights, nesting) - the pragmatic answer is depth caps plus per-node pagination rather than full virtualization. The immutable-update structure (rebuilding only the changed path, leaving sibling subtrees by reference) also helps memo skip untouched subtrees.

Accessibility. Comment trees map onto the ARIA tree pattern: role="tree" on the container, role="treeitem" per comment with aria-expanded reflecting collapse, and role="group" on the replies list, plus aria-level for depth if you want explicit level announcement. Keyboard support (expand/collapse, navigate) and focus management for the reply form round it out. Even using semantic nested lists with aria-expanded is a solid baseline; naming the tree pattern signals depth.

Edge cases handled. Empty (no comments → a prompt). A comment with no replies (no toggle shown, aria-expanded omitted). Collapse hides the whole subtree. The depth cap handles arbitrarily deep threads. The reply form is per-node and closes on submit/cancel. Optimistic insert makes replies appear instantly (with server reconcile/rollback in production). Deleting a comment with replies is a real design question (Reddit shows "[deleted]" but keeps the subtree) - worth mentioning as it uses the same update machinery.

Extensions

The design absorbs follow-ups locally:

Summary callouts

Important: Nested comments is the canonical recursive component problem: a CommentNode renders a CommentNode for each reply, yielding arbitrary depth from one definition. Data model decision: a nested tree (replies: Comment[]) mirrors the UI and renders trivially but makes a deep update an O(depth) immutable path-rebuild; a normalized map ({id → {…, childIds}} + rootIds) makes updates and lookups O(1) but reconstructs the tree to render - normalize for large/write-heavy systems. State placement (the key judgment): node-local presentational state (collapse, reply-form-open) lives in each CommentNode; the comment data (mutated by replies) is the single source of truth at the root, reached via an onReply callback threaded unchanged through every recursion level so a reply at any depth calls the root handler. Immutable nested updates rebuild only the root-to-target path (sibling subtrees keep their reference, so memo skips them). Cap depth (replace deep nesting with a "continue thread" link - avoids off-screen indentation and runaway recursion). React.memo each node so one node's collapse doesn't re-render the tree. Accessibility: the ARIA tree pattern (role=tree/treeitem/group, aria-expanded).

Interview: Lead with recursion, the data-model tradeoff, and state placement: "This is a recursive component - a CommentNode renders a CommentNode for each reply, so one definition handles any depth. For data I'd start with a nested tree since it mirrors the UI, but I'd note that updating a deep node means rebuilding the whole path immutably, which is O(depth), so for a large comment system I'd normalize into a flat map keyed by id with childIds, making updates and lookups O(1). On state placement: collapse and the reply-form-open flag are node-local presentational state, but the comment data is the source of truth at the root because replies mutate the tree - so I thread a single onReply callback unchanged through every recursion level, and a reply at any depth calls the root handler that rebuilds the tree. I'd cap depth with a 'continue thread' link to avoid runaway indentation, memo each node so collapsing one doesn't re-render the tree, and use the ARIA tree pattern. For huge threads I'd add per-node 'load more replies.'" Recursion, the nested-vs-normalized tradeoff, root-owned data with threaded callbacks, and the depth cap is the senior answer.

What you should carry out of this case study

Nested comments is the canonical recursive-component problem: a CommentNode renders a CommentNode for each of its replies, so a single component definition produces a tree of arbitrary depth. The first real decision is the data model - a nested tree mirrors the UI and renders trivially but makes a deep update an O(depth) immutable path-rebuild, while a normalized flat map keyed by id with childIds makes updates and lookups O(1) at the cost of reconstructing the tree to render, so you normalize for large or write-heavy comment systems. The subtle, high-signal judgment is state placement in the recursion: node-local presentational state like collapse and the reply-form-open flag lives inside each CommentNode, while the comment data that replies mutate is the single source of truth at the root, reached through an onReply callback threaded unchanged through every recursion level so a reply at any depth invokes the root handler that rebuilds the tree immutably. Round it out with a depth cap that replaces runaway indentation with a continue-thread link, React.memo on each node so one node's collapse doesn't re-render the whole tree, the ARIA tree pattern for accessibility, and per-node load-more for huge threads. Building it this way demonstrates recursion, tree manipulation, and state-placement judgment together, which is exactly why interviewers use it.

The next case study returns to a bounded, finite component but with rich interaction and timing concerns: an image slider / carousel - exercising index state, autoplay with timers and cleanup, touch/keyboard navigation, transitions, and the accessibility of a rotating region. The carousel is next.

Back to Frontend System Design