Hey folks, Rahul here ๐
Comments seem trivial until you need nesting. Reddit, Hacker News, GitHub โ they all solve the same recursive data problem differently. And the frontend challenges go way beyond rendering a tree: think optimistic inserts, collapse state, real-time streaming, pagination within threads, and sort order that doesn't break when new comments arrive mid-scroll.
This is one of those problems where a naive recursive approach works for 50 comments but melts the browser at 5,000. Let's build it right.
R โ Requirements
Functional Requirements
- Display comments in a threaded/nested tree structure
- Users can reply to any comment (creating child nodes)
- Collapse/expand individual threads
- Sort comments by: newest, oldest, most liked, controversial
- Like/dislike individual comments
- Edit and delete own comments
- Load more replies (pagination within threads)
- "Jump to parent" for deeply nested contexts
Non-Functional Requirements
- Performance: Render 5,000+ comments without jank
- Optimistic UI: New comments appear instantly before server confirmation
- Real-time: New comments from other users stream in without refresh
- Accessibility: Proper tree role semantics and focus management
- Deep linking: Direct URL to any comment with context
A โ Architecture
Data Structure: Flat vs. Nested
The core architectural decision is how you store and render the tree. There are two approaches:
Option A: Recursive Nested Objects โ
interface NestedComment {
id: string;
content: string;
children: NestedComment[]; // Recursive
}
// Problem: Updating a deeply nested comment requires deep cloning
// Problem: React can't efficiently diff deep trees
// Problem: Normalization is painfulOption B: Flat Map + Parent References โ
interface Comment {
id: string;
parentId: string | null;
content: string;
authorId: string;
depth: number;
childCount: number;
createdAt: string;
}
// Store as a flat normalized map
interface CommentsState {
byId: Record<string, Comment>;
rootIds: string[]; // Top-level comment IDs in sort order
childrenMap: Record<string, string[]>; // parentId โ sorted child IDs
collapsedIds: Set<string>; // Which threads are collapsed
}Why flat? Updating a single comment is O(1) โ just update byId[id]. Adding a child is O(1) โ push to childrenMap[parentId]. React diffs are shallow. This is the Reddit/HN pattern.
Component Tree
CommentsSection
โโโ CommentSortBar // Sort controls
โโโ CommentComposer // Top-level "Add comment" form
โโโ CommentThread // Recursive renderer
โ โโโ CommentNode // Single comment
โ โ โโโ CommentContent // Rendered markdown/text
โ โ โโโ CommentActions // Like, Reply, Edit, Delete
โ โ โโโ ReplyComposer // Inline reply form (toggled)
โ โโโ CollapseToggle // [โ] / [+] thread toggle
โ โโโ CommentThread[] // Recursive children
โโโ LoadMoreButton // "Show 42 more replies"Rendering Strategy: Flattened Virtual List
Here's the key insight: don't render the tree recursively with React components. Instead, flatten the visible tree into a list and render it linearly with indentation:
function flattenVisibleComments(
state: CommentsState,
maxDepth: number = 10
): FlatComment[] {
const result: FlatComment[] = [];
function walk(id: string, depth: number) {
const comment = state.byId[id];
if (!comment) return;
result.push({ ...comment, depth, isCollapsed: state.collapsedIds.has(id) });
// Don't recurse into collapsed threads
if (state.collapsedIds.has(id)) return;
// Don't recurse beyond max depth (show "Continue thread โ")
if (depth >= maxDepth) return;
const children = state.childrenMap[id] || [];
for (const childId of children) {
walk(childId, depth + 1);
}
}
for (const rootId of state.rootIds) {
walk(rootId, 0);
}
return result;
}
// Render with indentation via margin-left
{flatList.map(comment => (
<div key={comment.id} style={{ marginLeft: comment.depth * 24 }}>
<CommentNode comment={comment} />
</div>
))}This approach means you can plug in a virtual list (like react-window) directly โ it's just a flat array with varying indentation. At 5,000 comments, only ~20 are in the viewport.
D โ Data Model
Server Response
interface CommentResponse {
id: string;
parent_id: string | null;
content: string;
author: {
id: string;
username: string;
avatar_url: string;
};
likes_count: number;
dislikes_count: number;
child_count: number; // Total descendants (for "N replies" label)
depth: number; // Server-computed for validation
created_at: string;
updated_at: string;
is_edited: boolean;
user_vote: 'like' | 'dislike' | null; // Current user's vote
}
// API returns paginated flat list, client builds the tree
interface CommentsPageResponse {
comments: CommentResponse[];
total_count: number;
has_more: boolean;
cursor: string; // For cursor-based pagination
}Optimistic Comment State
interface OptimisticComment extends Comment {
_optimistic: true;
_tempId: string; // Client-generated UUID
_status: 'pending' | 'confirmed' | 'failed';
}
function addOptimisticComment(state: CommentsState, draft: CommentDraft): CommentsState {
const tempId = crypto.randomUUID();
const optimistic: OptimisticComment = {
id: tempId,
_tempId: tempId,
_optimistic: true,
_status: 'pending',
parentId: draft.parentId,
content: draft.content,
authorId: currentUser.id,
depth: draft.parentId ? state.byId[draft.parentId].depth + 1 : 0,
childCount: 0,
createdAt: new Date().toISOString(),
};
return {
...state,
byId: { ...state.byId, [tempId]: optimistic },
rootIds: draft.parentId ? state.rootIds : [tempId, ...state.rootIds],
childrenMap: draft.parentId
? { ...state.childrenMap, [draft.parentId]: [tempId, ...(state.childrenMap[draft.parentId] || [])] }
: state.childrenMap,
};
}
// On server confirmation: swap tempId โ real ID
function confirmComment(state: CommentsState, tempId: string, realComment: Comment): CommentsState {
const newById = { ...state.byId };
delete newById[tempId];
newById[realComment.id] = realComment;
// Update all references from tempId to realId
const updateRefs = (ids: string[]) => ids.map(id => id === tempId ? realComment.id : id);
return {
...state,
byId: newById,
rootIds: updateRefs(state.rootIds),
childrenMap: Object.fromEntries(
Object.entries(state.childrenMap).map(([k, v]) => [
k === tempId ? realComment.id : k,
updateRefs(v)
])
),
};
}I โ Interface Definition
API Endpoints
// GET /api/comments?post_id={id}&sort={sort}&cursor={cursor}&limit=20
// Returns: CommentsPageResponse (flat list, sorted)
// GET /api/comments/{id}/replies?cursor={cursor}&limit=10
// Returns: CommentsPageResponse (children of a specific comment)
// POST /api/comments
// Body: { post_id, parent_id?, content }
// Returns: CommentResponse
// PATCH /api/comments/{id}
// Body: { content }
// Returns: CommentResponse
// DELETE /api/comments/{id}
// Returns: 204 (soft delete โ shows "[deleted]")
// POST /api/comments/{id}/vote
// Body: { type: "like" | "dislike" | "none" }
// Returns: { likes_count, dislikes_count }Real-Time Events
// Subscribe to comment changes via WebSocket/SSE
interface CommentEvent {
type: 'new_comment' | 'edit_comment' | 'delete_comment' | 'vote_update';
comment: CommentResponse;
post_id: string;
}
// Handle real-time inserts without disrupting scroll
function handleNewComment(event: CommentEvent, state: CommentsState): CommentsState {
// Don't insert if it's our own optimistic comment
if (state.byId[event.comment.id]) return state;
const comment = normalizeComment(event.comment);
// If it's a root comment while sorted by "newest", prepend
// If sorted by "most liked", don't auto-insert โ show "N new comments" banner
if (!comment.parentId && sortMode === 'newest') {
return insertComment(state, comment);
}
// For replies, insert into the parent's children
if (comment.parentId && state.byId[comment.parentId]) {
return insertReply(state, comment);
}
// Otherwise, buffer and show notification
return { ...state, pendingCount: state.pendingCount + 1 };
}O โ Optimizations
1. Depth Capping with "Continue Thread"
// Reddit caps at depth 10, then shows "Continue this thread โ"
const MAX_RENDER_DEPTH = 10;
function CommentThread({ commentId, depth }: { commentId: string; depth: number }) {
const comment = useComment(commentId);
const children = useChildren(commentId);
if (depth >= MAX_RENDER_DEPTH && children.length > 0) {
return (
<div>
<CommentNode comment={comment} depth={depth} />
<Link to={`/comments/${commentId}`} className="text-primary text-sm ml-6">
Continue this thread โ
</Link>
</div>
);
}
// ... normal recursive render
}2. Collapse State Persistence
// Persist collapse state in sessionStorage so refreshes maintain context
function useCollapseState(postId: string) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
const saved = sessionStorage.getItem(`collapsed-${postId}`);
return saved ? new Set(JSON.parse(saved)) : new Set();
});
const toggle = useCallback((commentId: string) => {
setCollapsed(prev => {
const next = new Set(prev);
next.has(commentId) ? next.delete(commentId) : next.add(commentId);
sessionStorage.setItem(`collapsed-${postId}`, JSON.stringify([...next]));
return next;
});
}, [postId]);
return { collapsed, toggle };
}3. Deep Link with Context Loading
// URL: /post/123#comment-456
// Need to load the comment + its ancestor chain for context
async function loadCommentWithContext(commentId: string): Promise<Comment[]> {
// Server endpoint returns the comment + all ancestors up to root
const { data } = await api.get(`/comments/${commentId}/context`);
// Returns: [root, ..., parent, target_comment]
// Auto-expand all ancestors and scroll to target
for (const comment of data) {
expandThread(comment.id);
}
// After render, scroll to the target comment
requestAnimationFrame(() => {
document.getElementById(`comment-${commentId}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
return data;
}4. Virtualized Comments for Large Threads
// Using the flattened list with react-window
import { VariableSizeList } from 'react-window';
function VirtualizedComments({ flatComments }: { flatComments: FlatComment[] }) {
const listRef = useRef<VariableSizeList>(null);
const heights = useRef<Map<string, number>>(new Map());
const getItemSize = (index: number) => {
return heights.current.get(flatComments[index].id) || 120; // Estimated height
};
const setItemSize = (id: string, height: number) => {
heights.current.set(id, height);
listRef.current?.resetAfterIndex(
flatComments.findIndex(c => c.id === id)
);
};
return (
<VariableSizeList
ref={listRef}
height={window.innerHeight - 200}
itemCount={flatComments.length}
itemSize={getItemSize}
overscanCount={5}
>
{({ index, style }) => (
<div style={style}>
<MeasuredComment
comment={flatComments[index]}
onMeasure={setItemSize}
/>
</div>
)}
</VariableSizeList>
);
}5. Markdown Rendering with Sanitization
import DOMPurify from 'dompurify';
import { marked } from 'marked';
function CommentContent({ content }: { content: string }) {
const html = useMemo(() => {
const raw = marked.parse(content, { breaks: true });
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'blockquote'],
ALLOWED_ATTR: ['href', 'rel', 'target'],
});
}, [content]);
return (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}Production Gotchas Rahul Has Debugged ๐ฅ
- Sort + Real-time Conflict: If sorted by "most liked" and a new comment arrives via WebSocket, don't inject it at the top โ it has 0 likes. Show a "New comments available" banner instead.
- Delete Semantics: Never hard-delete comments with children. Show "[deleted]" placeholder to preserve thread structure. Only hard-delete leaf comments.
- Reply Form Focus: When "Reply" is clicked, the inline composer appears and steals focus. On mobile, this triggers the keyboard, which scrolls the page. Use
scrollIntoView({ block: 'nearest' })to minimize disruption. - Recursive useEffect: Don't fetch children inside a recursive component's useEffect โ you'll fire N requests for N visible threads simultaneously. Batch: fetch the first 2 levels of comments in a single API call.
- XSS via Markdown: Always sanitize user-generated HTML. Markdown libraries like
markedcan produce<img onerror="alert(1)">from crafted input. DOMPurify is non-negotiable.
Next up: #9: Design a Collaborative Text Editor โ CRDTs vs OT, cursor presence, conflict resolution, and the operational complexity behind Google Docs. โ๏ธ