Nested Comments Thread
Machine-coding brief for nested comments — tree model, reply UX, collapse, pagination of roots, and a11y.
intermediate4 min read
- machine-coding
- interview
- react
- tree
Problem statement
Build a nested comments thread: top-level comments with arbitrary-depth replies, reply composer, and optional collapse. Interviewers score tree state updates (immutable insert by id), recursion vs flatten, and depth UX limits.
Requirements
Must have
- Render nested comments from data
- Reply to any comment (inline composer)
- Add top-level comment
- Show author, body, timestamp
- Indent by depth (cap visual depth, e.g. max 4 levels indent)
Should have
- Collapse / expand thread
- Delete (soft) own comment
- Sort roots by newest / top
Nice to have
- Upvote with optimistic count
- Lazy-load replies
- Markdown body
- Virtualize large threads
Planning (5 minutes out loud)
- Nested vs flat + parentId — flat is easier to update; nested is easier to render
- Normalize
byId+childrenMapis the production choice - Depth cap for indent + “Continue thread” link
- MVP — render recursive + reply insert; then collapse
- Ids with
crypto.randomUUID()
Architecture
CommentThread
├── Composer (top-level)
└── CommentList
└── CommentNode
├── CommentBody
├── Actions (reply, collapse)
├── ReplyComposer?
└── CommentList (children)
Data model
type Comment = {
id: string;
parentId: string | null;
author: string;
body: string;
createdAt: number;
};
// Working structure for UI:
type CommentNode = Comment & { children: CommentNode[] };
function buildTree(list: Comment[]): CommentNode[] {
const map = new Map<string, CommentNode>();
list.forEach((c) => map.set(c.id, { ...c, children: [] }));
const roots: CommentNode[] = [];
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
});
return roots;
}
Prefer keeping a flat array in React state and deriving the tree with useMemo.
Implementation sketch
function CommentThread({ initial }: { initial: Comment[] }) {
const [comments, setComments] = useState(initial);
const [replyTo, setReplyTo] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const tree = useMemo(() => buildTree(comments), [comments]);
function addComment(parentId: string | null, body: string) {
const trimmed = body.trim();
if (!trimmed) return;
const next: Comment = {
id: crypto.randomUUID(),
parentId,
author: "You",
body: trimmed,
createdAt: Date.now(),
};
setComments((prev) => [...prev, next]);
setReplyTo(null);
}
return (
<div>
<Composer onSubmit={(body) => addComment(null, body)} label="Add a comment" />
<CommentList
nodes={tree}
depth={0}
replyTo={replyTo}
setReplyTo={setReplyTo}
collapsed={collapsed}
toggleCollapse={(id) =>
setCollapsed((s) => {
const n = new Set(s);
n.has(id) ? n.delete(id) : n.add(id);
return n;
})
}
onReply={(parentId, body) => addComment(parentId, body)}
/>
</div>
);
}
function CommentList(props: {
nodes: CommentNode[];
depth: number;
/* ... */
}) {
const { nodes, depth } = props;
return (
<ul className="comments" style={{ ["--depth" as string]: depth }}>
{nodes.map((node) => (
<li key={node.id}>
<article aria-label={`Comment by ${node.author}`}>
<header>
<strong>{node.author}</strong>
<time>{new Date(node.createdAt).toLocaleString()}</time>
</header>
<p>{node.body}</p>
<button type="button" onClick={() => props.setReplyTo(node.id)}>
Reply
</button>
{node.children.length > 0 && (
<button type="button" onClick={() => props.toggleCollapse(node.id)}>
{props.collapsed.has(node.id) ? "Expand" : "Collapse"} replies
</button>
)}
{props.replyTo === node.id && (
<Composer
label={`Reply to ${node.author}`}
onSubmit={(body) => props.onReply(node.id, body)}
onCancel={() => props.setReplyTo(null)}
/>
)}
</article>
{!props.collapsed.has(node.id) && node.children.length > 0 && (
<CommentList {...props} nodes={node.children} depth={depth + 1} />
)}
</li>
))}
</ul>
);
}
Composer
function Composer({
onSubmit,
onCancel,
label,
}: {
onSubmit: (body: string) => void;
onCancel?: () => void;
label: string;
}) {
const [body, setBody] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(body);
setBody("");
}}
>
<label>
{label}
<textarea value={body} onChange={(e) => setBody(e.target.value)} required />
</label>
<button type="submit">Post</button>
{onCancel && (
<button type="button" onClick={onCancel}>
Cancel
</button>
)}
</form>
);
}
Accessibility essentials
- Nested lists (
ul/li) convey structure; don’t only use margin - Collapse buttons with clear names including count if known
- Focus the reply textarea when opening reply
- Articles labeled by author
Performance notes
- Derive tree once per comments change
- Deep trees: collapse by default below depth 3
- Huge threads: paginate roots; lazy-fetch children
- Avoid storing duplicate nested copies in state
Footguns
- Mutating nested objects when inserting replies
- Orphan nodes when parentId missing from map
- Infinite recursion if bad data has cycles — guard with visited set in production
- Uncontrolled depth indent pushing content off-screen
- Multiple open reply composers — usually allow one
replyToid
Interview out-loud answer
I’d keep comments flat with parentId and memo-build a tree for render. Adding a reply is appending one node. Collapse state is a set of ids. Visual depth is capped even if data is deeper. MVP is recursive list + reply; votes and lazy children are extensions. Normalized maps scale better if we later edit/delete deeply.
Related on this site
Further reading
- ARIA APG: Tree View (related navigation model)
- React: immutability