Nested Comments
Nested comments allow a user to reply to another comment, creating a conversation tree instead of one flat list.
Comment A
├── Reply B
│ ├── Reply C
│ └── Reply D
└── Reply E
The same comment UI appears at every level, so a recursive component is a natural way to render it.
Core idea: A comment renders its own content and then renders the same comment structure for its replies.
1. Why Comments Form a Tree
Each comment is a node with:
- its own data;
- one optional parent;
- zero or more child comments.
Node = comment
Edge = “is a reply to” relationship
Root node = top-level comment
Leaf node = comment with no replies
This same recursive structure appears in file explorers, nested menus, organization charts, categories, and JSON viewers.
2. Simple Nested Data Model
For a small thread, replies can be embedded directly inside each comment.
const comments = [
{
id: "c1",
author: { id: "u1", name: "Asha" },
body: "What do you think about cursor pagination?",
createdAt: "2026-09-15T10:30:00Z",
score: 18,
replies: [
{
id: "c2",
author: { id: "u2", name: "Ravi" },
body: "It works well when the thread changes frequently.",
createdAt: "2026-09-15T10:42:00Z",
score: 9,
replies: [],
},
],
},
];
The base case is a comment whose replies array is empty. At that point the recursion stops.
Has replies? → render another CommentList
No replies? → return without another recursive call
3. Basic Recursive React Component
Use semantic nested lists to preserve the parent-child relationship.
function CommentList({ comments, depth = 0 }) {
if (comments.length === 0) return null;
return (
<ol className="comment-list">
{comments.map(comment => (
<li key={comment.id} className="comment-list__item">
<Comment comment={comment} depth={depth} />
<CommentList
comments={comment.replies}
depth={depth + 1}
/>
</li>
))}
</ol>
);
}
function Comment({ comment, depth }) {
return (
<article className="comment" data-depth={depth}>
<header>
<strong>{comment.author.name}</strong>
{" · "}
<time dateTime={comment.createdAt}>
{formatRelativeTime(comment.createdAt)}
</time>
</header>
<p>{comment.body}</p>
<footer>
<button type="button">Reply</button>
<span>{comment.score} points</span>
</footer>
</article>
);
}
The component receives smaller nested data on every call:
CommentList(root comments)
└── CommentList(root[0].replies)
└── CommentList(reply[0].replies)
└── [] → stop
Always use the stable comment ID as the React key. An array index can attach state to the wrong comment when replies are inserted, deleted, sorted, or paginated.
4. Add Collapse and Expand Behavior
Large threads should let users collapse subtrees they are not reading.
import { useId, useState } from "react";
function ThreadedComment({ comment, depth = 0 }) {
const [isExpanded, setIsExpanded] = useState(true);
const repliesId = useId();
const hasReplies = comment.replies.length > 0;
return (
<li>
<article className="comment">
<header>
<strong>{comment.author.name}</strong>
</header>
<p>{comment.body}</p>
{hasReplies && (
<button
type="button"
aria-expanded={isExpanded}
aria-controls={repliesId}
onClick={() => setIsExpanded(value => !value)}
>
{isExpanded
? "Hide replies"
: `Show ${comment.replyCount} replies`}
</button>
)}
</article>
{hasReplies && (
<ol id={repliesId} hidden={!isExpanded}>
{comment.replies.map(reply => (
<ThreadedComment
key={reply.id}
comment={reply}
depth={depth + 1}
/>
))}
</ol>
)}
</li>
);
}
aria-expanded communicates the current state, while aria-controls identifies the reply group controlled by the button.
5. Nested Data vs Normalized Data
Embedding replies is easy to read, but deeply nested immutable updates become repetitive. A larger application often stores comments by ID.
const commentState = {
rootIds: ["c1", "c8"],
byId: {
c1: {
id: "c1",
parentId: null,
authorId: "u1",
body: "Root comment",
childIds: ["c2", "c5"],
replyCount: 12,
},
c2: {
id: "c2",
parentId: "c1",
authorId: "u2",
body: "Reply",
childIds: [],
replyCount: 0,
},
},
};
| Nested model | Normalized model |
|---|---|
| Simple to understand and render | Simple lookup and update by ID |
| Good for small, fully loaded trees | Good for cached and paginated threads |
| Deep updates copy every ancestor | Updates replace one record plus relevant ID lists |
| May duplicate entities | Keeps one canonical comment record |
Normalization changes storage, not the visible tree. The renderer follows rootIds and childIds recursively.
6. Render Normalized Comments
function CommentBranch({ commentId, commentsById, depth = 0 }) {
const comment = commentsById[commentId];
if (!comment) return null;
return (
<li>
<Comment comment={comment} depth={depth} />
{comment.childIds.length > 0 && (
<ol>
{comment.childIds.map(childId => (
<CommentBranch
key={childId}
commentId={childId}
commentsById={commentsById}
depth={depth + 1}
/>
))}
</ol>
)}
</li>
);
}
The identity of the comment and its location in the tree are explicit. A cache or store can update one comment without rebuilding the entire nested object manually.
7. Immutable Tree Updates
React state should be treated as read-only.
For a small nested model, recursively copy only the path to the changed node:
function updateComment(comments, targetId, update) {
return comments.map(comment => {
if (comment.id === targetId) {
return { ...comment, ...update };
}
return {
...comment,
replies: updateComment(comment.replies, targetId, update),
};
});
}
setComments(current =>
updateComment(current, "c2", { score: 10 })
);
This version is easy to learn but visits every branch. A production helper can stop once the target is found, and normalized state avoids tree traversal for direct record updates.
Do not mutate a nested reply directly:
// Avoid.
comments[0].replies[0].score += 1;
8. API Design and Pagination
Returning an entire comment tree in one response does not scale for a large discussion.
A practical API can return:
- one cursor-paginated page of top-level comments;
- a small preview of replies;
- the total reply count;
- a cursor for loading more children of a particular comment.
GET /posts/42/comments?sort=top&cursor=abc
GET /comments/c1/replies?cursor=def
{
"items": [
{
"id": "c1",
"parentId": null,
"body": "Root comment",
"replyCount": 12,
"replyPreviewIds": ["c2", "c3"],
"nextReplyCursor": "def"
}
],
"nextCursor": "abc-next"
}
Pagination exists at multiple sibling groups:
Top-level comments → cursor A
Replies to c1 → cursor B
Replies to c2 → cursor C
Store loading, error, cursor, and completion state per parent comment, not as one global isLoadingReplies flag.
9. Adding a Reply
A reply mutation needs enough information to connect the new comment to its parent.
POST /comments
Content-Type: application/json
{
"postId": "42",
"parentId": "c1",
"body": "This is my reply."
}
On success, a normalized client can:
- add the new comment to
byId; - append its ID to the parent's
childIds; - increment the parent's
replyCount; - keep the current sibling sort order consistent.
For an optimistic reply, create a temporary client ID and visible pending state. Replace it with the server record after success, or show retry/remove actions after failure.
Do not silently lose a failed reply that the user spent time writing.
10. Voting and Optimistic Updates
Voting is a common optimistic action:
User votes
↓
Update visible score immediately
↓
Send mutation to server
↓
Success → keep confirmed state
Failure → roll back and explain
Track the user's vote separately from the displayed score so repeated clicks and direction changes are predictable.
{
score: 25,
viewerVote: 1 // -1, 0, or 1
}
The server remains authoritative. Reconcile returned scores because moderation, rate limits, or concurrent voting may make the final value differ from the optimistic calculation.
11. Deletion and Moderation
Deleting a parent must not accidentally remove an entire valuable conversation.
A common approach keeps a tombstone when replies exist:
[deleted]
└── Existing reply
└── Existing reply
The record may retain its ID, parent relationship, children, and timestamps while hiding personal or removed content.
Distinguish states such as:
- deleted by author;
- removed by moderator;
- pending moderation;
- collapsed because of score or policy;
- unavailable to the current viewer.
Authorization must be enforced on the server. Hiding an edit or delete button in the UI is not security.
12. Sorting the Tree
Sorting happens among siblings, not by flattening every descendant into one list.
Sort root comments
Sort children of c1
Sort children of c2
...independently
Common strategies include newest, oldest, top score, and a ranked relevance score.
When the user changes sorting:
- use a distinct cache key;
- reset or retrieve the correct cursors;
- preserve drafts where appropriate;
- do not merge pages from different orders.
13. Prevent Invalid Trees
Production code should not assume all incoming data is valid.
Guard against:
- a comment referencing itself as its parent;
- cycles such as
A → B → A; - missing parent records;
- duplicate child IDs;
- extreme or malicious depth.
visited IDs contains current ID?
└── yes → stop and report invalid data
depth exceeds safe limit?
└── yes → stop nesting or offer “continue thread”
The database should prevent cycles and validate parent/post relationships. A client-side visited set and depth guard provide defense in depth.
14. Deep Nesting and Responsive Layout
Adding a fixed left margin at every level eventually leaves no room for the comment body on a narrow screen.
.comment-list {
list-style: none;
margin: 0;
padding: 0;
}
.comment-list .comment-list {
margin-inline-start: min(1.25rem, 4vw);
border-inline-start: 2px solid var(--thread-line-color);
padding-inline-start: min(1rem, 3vw);
}
For very deep threads, cap visual indentation and provide a Continue thread view. This improves readability and bounds the amount of recursively rendered content.
Virtualizing an arbitrary tree is harder than virtualizing a flat list because rows expand, collapse, and vary in height. First paginate and collapse subtrees; introduce virtualization only after measurement shows it is necessary.
15. Accessibility
Nested lists communicate hierarchy without requiring a complex tree widget.
Use:
<ol>or<ul>for comment groups;<li>for each comment and its replies;<article>for a self-contained comment;- a meaningful author label and
<time dateTime="...">; - native buttons for Reply, Vote, Collapse, Edit, and Delete;
aria-expandedon controls that collapse replies;- visible focus indicators and logical keyboard order.
Do not add role="tree" unless implementing the complete tree-view interaction model. A discussion thread is usually read as document content, not operated like a file explorer.
If new replies appear live, avoid announcing every background update. Notify users only when it is useful, and let them choose when to insert new content into their current reading position.
16. Security and User-Generated Content
Comment text is untrusted input.
React escapes ordinary string values:
<p>{comment.body}</p>
Avoid rendering raw HTML with dangerouslySetInnerHTML. If rich text or Markdown is supported, parse it with a restricted feature set and sanitize the generated HTML using a maintained sanitizer.
Also consider:
- server-side validation and length limits;
- authorization for editing, deleting, and moderation;
- rate limiting and spam controls;
- safe link attributes and URL validation;
- attachment validation;
- preserving audit information according to policy.
Client-side validation improves feedback but cannot replace server-side enforcement.
17. Performance Strategy
Use progressive disclosure instead of rendering the entire discussion immediately.
Initial request
├── first page of root comments
└── small reply previews
User expands a branch
└── fetch its next reply page
User collapses a branch
└── keep cache, remove or hide DOM as appropriate
Practical techniques include:
- cursor pagination for roots and reply groups;
- lazy loading collapsed branches;
- caching replies by parent ID and sort order;
- aborting requests when their query changes;
- limiting visible depth;
- memoizing only components proven expensive through profiling;
- preserving stable object references when unrelated comments update.
Large data does not automatically require one global store. Use a server-state cache for fetched pages and local state for UI concerns such as draft text and collapsed branches.
18. Common Mistakes
| Mistake | Better approach |
|---|---|
| Using array indexes as keys | Use stable comment IDs |
| Fetching the entire tree at once | Paginate roots and replies per parent |
| Storing one global replies-loading flag | Track request state by parent and cursor |
| Mutating nested arrays directly | Return immutable copies or normalize state |
| Deleting a parent and all descendants unintentionally | Preserve the branch with an appropriate tombstone |
| Rendering unlimited depth and indentation | Cap visible depth and provide a continuation view |
| Trusting the data cannot contain a cycle | Validate on the server and guard during traversal |
| Mixing pages from different sort orders | Include sort and filters in the cache key |
| Rendering raw user HTML | Sanitize supported rich content or render plain text |
| Using tree ARIA roles for visual indentation | Prefer semantic document and list structure |
| Losing a reply draft after a failed request | Preserve it and provide retry or recovery |
19. Testing Checklist
Rendering
- Root comments and every reply level render in the correct order.
- Empty replies stop recursion.
- Stable keys preserve the correct component state after reordering.
- Collapsing one branch does not affect unrelated branches.
- Deep threads remain readable on narrow screens.
Data and Mutations
- Reply pages append to the correct parent.
- Duplicate IDs are rejected or merged safely.
- Failed replies and votes roll back or provide recovery.
- Deleted parents preserve allowed descendants.
- Changing sort order does not reuse incompatible cursors.
- Cycles and excessive depth stop safely.
Accessibility and Security
- Hierarchy is represented by valid nested lists.
- Every action is keyboard accessible and has a clear name.
- Collapse controls expose accurate expanded state.
- Focus remains predictable when replies are inserted or removed.
- Malicious HTML and URLs cannot execute.
Official References
- React: Rendering Lists
- React: Updating Arrays in State
- W3C WAI: Content Structure and Nested Lists
- MDN: ARIA
commentRole
Summary
Nested comments combine a recursive tree UI with stable identity, branch-level pagination, safe mutations, and progressive disclosure.
Think of the thread as two cooperating systems:
Data layer
├── comment records by stable ID
├── parent-child relationships
├── cursor and request state per branch
└── mutation and moderation rules
UI layer
├── recursively renders visible branches
├── collapses and lazy-loads replies
├── preserves semantic hierarchy
└── limits depth and DOM growth
Recursion explains how the tree is displayed. Data modeling, pagination, validation, security, and accessibility determine whether it remains reliable in a real application.