Free lesson · Frontend System Design
Reddit-Style Nested Comments - Full LLD Case Study
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:
CommentTree- the root container. Owns the comment data (via auseCommentshook) and the reply handler, and renders the top-level comments asCommentNodes. This is where the data and all mutations live.CommentNode- the recursive component. Renders one comment (its body and actions) and recursively renders each of its replies as aCommentNode. This self-reference is what produces arbitrary depth from one definition. Each node also owns its own presentational state - whether it's collapsed and whether its reply form is open.CommentBody- presentational; the author, text, timestamp.CommentActions- presentational; the reply/collapse/upvote controls, emitting events up.ReplyForm- the inline form shown when replying; controlled, emits the submitted text up.
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 comment data (the tree) - lives at the root (
CommentTree/useComments), not per node. Why: replies, edits, and deletes mutate the tree, and a mutation deep in the tree must produce a new tree from the root. There's one source of truth, owned at the top, and mutations go there via the threaded callbacks. Putting comment data in individual nodes would make tree-wide updates impossible. - Collapse/expand state - lives locally in each
CommentNode(auseStateper node). Why: collapse is purely presentational and node-specific - whether this thread is folded affects nothing but this node's rendering. Colocating it keeps it simple and contained, and toggling one node's collapse doesn't touch any shared state. (If you needed "collapse all" or to persist collapse state across reloads, you'd lift it to aSetof collapsed ids at the root - a deliberate trade for global control. Local is the right default.) - Reply-form-open state - also local to each
CommentNode(areplyingboolean). Why: whether this node is showing its reply form is node-local presentational state. (If you wanted "only one reply form open at a time," you'd lift a singleactiveReplyIdto the root - again, a deliberate trade.)
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:
- "Add upvotes/sorting" -
CommentActionsemitsonVote(id)threaded to the root likeonReply; sorting reordersrepliesat render time. Localized. - "Edit / delete" - same threaded-callback-to-root pattern with
editComment/deleteCommentupdate functions (and the normalized model makes these O(1)). - "Only one reply form open at a time" - lift a single
activeReplyIdto the root instead of per-nodereplying. A deliberate state-placement change. - "Collapse all / persist collapse" - lift collapse into a
Setof collapsed ids at the root. Same deliberate trade. - "Real-time new comments" - subscribe and apply incoming comments via the same
addReply-style updates (the real-time case study). The update layer is reused. - "Scale to huge threads" - switch to the normalized model for O(1) updates and add per-node "load more replies." The components barely change.
Summary callouts
Important: Nested comments is the canonical recursive component problem: a
CommentNoderenders aCommentNodefor 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 eachCommentNode; the comment data (mutated by replies) is the single source of truth at the root, reached via anonReplycallback 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, somemoskips them). Cap depth (replace deep nesting with a "continue thread" link - avoids off-screen indentation and runaway recursion).React.memoeach 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.