Skip to main content

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 modelNormalized model
Simple to understand and renderSimple lookup and update by ID
Good for small, fully loaded treesGood for cached and paginated threads
Deep updates copy every ancestorUpdates replace one record plus relevant ID lists
May duplicate entitiesKeeps 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:

  1. add the new comment to byId;
  2. append its ID to the parent's childIds;
  3. increment the parent's replyCount;
  4. 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-expanded on 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​

MistakeBetter approach
Using array indexes as keysUse stable comment IDs
Fetching the entire tree at oncePaginate roots and replies per parent
Storing one global replies-loading flagTrack request state by parent and cursor
Mutating nested arrays directlyReturn immutable copies or normalize state
Deleting a parent and all descendants unintentionallyPreserve the branch with an appropriate tombstone
Rendering unlimited depth and indentationCap visible depth and provide a continuation view
Trusting the data cannot contain a cycleValidate on the server and guard during traversal
Mixing pages from different sort ordersInclude sort and filters in the cache key
Rendering raw user HTMLSanitize supported rich content or render plain text
Using tree ARIA roles for visual indentationPrefer semantic document and list structure
Losing a reply draft after a failed requestPreserve 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​


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.