Nested Comments System
by Pratik Rai
Read the full write-upBuild a threaded comment system. Comments nest to any depth, each one can be replied to, and deleting a comment takes its replies with it.
The comment UI is written. What is missing is the recursion and the two tree updates.
Requirements
A comment renders its own children, to any depth.
Replying to a comment adds the new reply under it, wherever it sits in the tree.
Deleting a comment removes it and everything nested beneath it.
An empty reply is not posted.
The tree in state is never mutated — updates return new objects and arrays.
Notes
Each comment is
{ id, author, body, children }.makeId()gives you a fresh id.Edit, collapse and vote are natural follow-ups and are out of scope.
Hints
Nested Comments System (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3let nextId = 100;
4const makeId = () => 'c' + nextId++;
5
6const INITIAL = [
7 {
8 id: 'c1',
9 author: 'ada',
10 body: 'Threading is easier than it looks once the shape is right.',
11 children: [
12 {
13 id: 'c2',
14 author: 'linus',
15 body: 'Agreed. The recursion is the easy half.',
16 children: [],
17 },
18 ],
19 },
20 {
21 id: 'c3',
22 author: 'grace',
23 body: 'Watch out for updating a node deep in the tree.',
24 children: [],
25 },
26];
27
28// Both updates are the same walk: rebuild the tree, changing only the branch
29// that contains the target. Nothing is mutated, so React sees a new array at
30// every level that actually changed and an unchanged reference everywhere else.
31function addReply(nodes, parentId, reply) {
32 return nodes.map((node) => {
33 if (node.id === parentId) {
34 return { ...node, children: [...node.children, reply] };
35 }
36 if (node.children.length === 0) return node;
37 return { ...node, children: addReply(node.children, parentId, reply) };
38 });
39}
40
41function removeComment(nodes, id) {
42 return nodes
43 .filter((node) => node.id !== id)
44 .map((node) =>
45 node.children.length === 0
46 ? node
47 : { ...node, children: removeComment(node.children, id) }
48 );
49}
50
51function Comment({ comment, onReply, onDelete }) {
52 const [replying, setReplying] = useState(false);
53 const [draft, setDraft] = useState('');
54
55 const submit = () => {
56 const body = draft.trim();
57 if (body === '') return;
58 onReply(comment.id, body);
59 setDraft('');
60 setReplying(false);
61 };
62
63 return (
64 <div className="comment">
65 <p className="author">{comment.author}</p>
66 <p className="body">{comment.body}</p>
67
68 <div className="actions">
69 <button className="link" onClick={() => setReplying((r) => !r)}>
70 {replying ? 'Cancel' : 'Reply'}
71 </button>
72 <button
73 className="link link-danger"
74 onClick={() => onDelete(comment.id)}
75 >
76 Delete
77 </button>
78 </div>
79
80 {replying && (
81 <div className="composer">
82 <input
83 type="text"
84 value={draft}
85 placeholder="Write a reply"
86 onChange={(event) => setDraft(event.target.value)}
87 onKeyDown={(event) => {
88 if (event.key === 'Enter') submit();
89 }}
90 aria-label={'Reply to ' + comment.author}
91 />
92 <button className="send" onClick={submit}>Reply</button>
93 </div>
94 )}
95
96 {comment.children.length > 0 && (
97 <div className="children">
98 {comment.children.map((child) => (
99 // The component renders itself. That is the whole trick — the depth
100 // of the thread is not something the component has to know about.
101 <Comment
102 key={child.id}
103 comment={child}
104 onReply={onReply}
105 onDelete={onDelete}
106 />
107 ))}
108 </div>
109 )}
110 </div>
111 );
112}
113
114export default function App() {
115 const [comments, setComments] = useState(INITIAL);
116
117 const handleReply = (parentId, body) => {
118 const reply = { id: makeId(), author: 'you', body, children: [] };
119 setComments((current) => addReply(current, parentId, reply));
120 };
121
122 const handleDelete = (id) => {
123 setComments((current) => removeComment(current, id));
124 };
125
126 return (
127 <div className="thread">
128 <h1>Comments</h1>
129 {comments.map((comment) => (
130 <Comment
131 key={comment.id}
132 comment={comment}
133 onReply={handleReply}
134 onDelete={handleDelete}
135 />
136 ))}
137 </div>
138 );
139}How it works
Two recursions, and they are different kinds. The component recursion is the easy one: a comment renders comments, and the nesting takes care of itself.
The update recursion is where interviews are won or lost. The tempting version finds the node and pushes into its children array — which works, briefly, and then does not re-render, because the array React is holding is the same object it was before. The version here rebuilds the path: every ancestor of the changed node becomes a new object, and every branch that was not touched keeps its original reference. That is also what makes React.memo worthwhile on a large thread, since untouched subtrees compare equal.
Deleting needs no special handling for descendants. Filtering the node out removes the entire subtree with it, because the children live inside the node rather than in a flat list keyed by parent.