UI build: nested comments
Index first, then link; keep orphans; key each mapped element; count the whole subtree; copy only the path.
Predict first
Before you read: predict
Proctors discuss a flagged attempt in a thread. The API returns the thread's comments flat, newest first, and the console builds and renders the tree:
The console renders <Thread rows={rows} />.
What does the thread show?
function buildTree(rows) { const byId = new Map(); const roots = []; for (const row of rows) { const node = { ...row, replies: [] }; byId.set(row.id, node); const parent = byId.get(row.parentId); if (parent) parent.replies.push(node); else roots.push(node); } return roots;} function Comment({ node }) { return ( <li> {node.author}: {node.body} <ul>{node.replies.map((r) => <Comment key={r.id} node={r} />)}</ul> </li> );} function Thread({ rows }) { return <ul>{buildTree(rows).map((n) => <Comment key={n.id} node={n} />)}</ul>;} const rows = [ { id: 14, parentId: 12, author: 'Ana', body: 'Agreed, void it.' }, { id: 13, parentId: null, author: 'Cleo', body: 'Camera was off at 10:07.' }, { id: 12, parentId: 11, author: 'Ben', body: 'Tab switch confirmed in the log.' }, { id: 11, parentId: null, author: 'Ana', body: 'Tab switch at 10:02?' },];The 30-second version
Rows arrive flat, each naming its parent. Put every row in a Map first, then link each to its parent; a row whose parent is absent stays a root. Render with a component that renders its replies with itself, a key on each mapped element. Count replies recursively. Add a reply by copying only the path down to it.
Index first, then link; keep orphans; key each mapped element; count the whole subtree; copy only the path.
How it works
Step 1 of 12
id, parentId (null at the top), author and body, in a sort order, not tree order.Analogy: An org chart from the payroll export (human resources)
An HR assistant pins up the company's org chart from the payroll export: one line per employee, with their manager's staff number, sorted by surname.
| At the HR office | In the thread |
|---|---|
| she writes one card per employee and files it by staff number before pinning anything | index every row in a Map (step 2) |
| she then pins each card under its manager's card, found by number, not by searching the pile | one linking pass, a lookup per row (step 3) |
| a manager listed after their reports is no problem: every card already exists | order stops mattering (step 4) |
| a report whose manager has left is pinned at the top, never binned | orphans stay as roots (step 5) |
| "team size" counts everyone under a manager, reports of reports included | the recursive count (step 9) |
| when someone joins, she redraws only the sheets from the director down to the new hire's manager | copy only the path (steps 10–11) |
| each card hangs by its name badge, not by "third card from the left" | a key from node.id on each mapped element (step 8) |
In real terms: two passes over the rows build the tree; one recursive component draws it.
Breaks down when: a matrix organisation gives someone two managers; a comment has exactly one parent.
Contrasting case: a one-level chat with no replies: a flat list, no tree to build.
Examples, run for real
4.1 ExamHall EXH-3631: replies above the comment they answer
The API returns a thread's comments in the order they were last edited. The first thread view linked each row as it was read, finding its parent with find among the rows read so far. Ana edited her comment after Ben and Cleo replied, so their replies came first (EXH-3631). MR !3931 indexes every row first.
4.1 EXH-3631 draft (find among rows read so far) · rows 5 (51 listed after its replies 52, 53) · comments shown 5 · top level 4 · page: Ben(Dev), Cleo, Ana, Fin
4.1 MR !3931 (index every row, then link) · rows 5 (51 listed after its replies 52, 53) · comments shown 5 · top level 2 · page: Ana(Ben(Dev), Cleo), Fin
Every comment shows both times. The draft's find saw only earlier rows, so Ben's and Cleo's replies became roots; indexing first nests them (steps 2–4).
4.2 Chat: 10,000 messages, and one reply added
A group chat's 10,000 messages, 100 roots with 99 replies each, built two ways; then one reply added with a path copy (the reference addReply).
4.2 10,000 messages · find per reply: 49,014,900 id comparisons · Map: 9,900 lookups, one per reply · roots 100
4.2 addReply to message 743 (reference) · roots that are the same object: 99 of 100 · nodes copied: 2 (701, 743) · old tree unchanged: true
The Map build looks each parent up once (step 3); find scans the list for every row. The insert copies the path and reuses the other roots (step 10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A group chat builds its thread like this; the API leaves deleted messages out, and Gil deleted message 32:
The chat renders <Thread rows={rows} />.
What does it show?
function buildTree(rows) { const byId = new Map(rows.map((r) => [r.id, { ...r, replies: [] }])); const roots = []; for (const node of byId.values()) { if (node.parentId === null) roots.push(node); else byId.get(node.parentId)?.replies.push(node); } return roots;} function Comment({ node }) { return ( <li> {node.author}: {node.body} <ul>{node.replies.map((r) => <Comment key={r.id} node={r} />)}</ul> </li> );} function Thread({ rows }) { return <ul>{buildTree(rows).map((n) => <Comment key={n.id} node={n} />)}</ul>;} const rows = [ { id: 31, parentId: null, author: 'Dina', body: 'Who has the venue address?' }, { id: 33, parentId: 32, author: 'Hana', body: 'It is 12 Quay Street.' }, { id: 34, parentId: 33, author: 'Ivo', body: 'Thanks, Hana!' }, { id: 35, parentId: 31, author: 'Eli', body: 'Gil posted it earlier.' },];Question 2 of 8
A video's comment section builds its tree with the index-then-link buildTree below and renders it with the same recursive Comment and Thread as P1:
Put the five authors in the order their comments appear on the page, top first.
function buildTree(rows) { const byId = new Map(rows.map((r) => [r.id, { ...r, replies: [] }])); const roots = []; for (const node of byId.values()) { const parent = byId.get(node.parentId); if (parent) parent.replies.push(node); else roots.push(node); } return roots;} const rows = [ { id: 41, parentId: null, author: 'Jo', body: 'Great cut at 2:10.' }, { id: 42, parentId: null, author: 'Kai', body: 'Audio drifts after 5:00.' }, { id: 43, parentId: 41, author: 'Lu', body: 'Agreed.' }, { id: 44, parentId: 43, author: 'Mo', body: 'Same for me.' }, { id: 45, parentId: 42, author: 'Ned', body: 'Fixed in the re-upload.' },];Your order
- 1empty
- 2empty
- 3empty
- 4empty
- 5empty
Tap the lines in the order you expect.
Question 3 of 8
A ticket-resale forum collapses each thread to one button:
The forum renders <Collapsed node={thread} />.
What does the button read?
function countReplies(node) { return node.replies.reduce((n, r) => n + countReplies(r), 0);} function Collapsed({ node }) { return <button>{node.author} ({countReplies(node)} replies)</button>;} const thread = { author: 'Oli', body: 'Selling 2 seats, row F.', replies: [ { author: 'Pam', body: 'Still free?', replies: [ { author: 'Oli', body: 'Yes.', replies: [{ author: 'Pam', body: 'Taking them.', replies: [] }] }, { author: 'Rex', body: 'Price?', replies: [] }, ] }, { author: 'Sid', body: 'Which date?', replies: [] }, ],};Question 4 of 8
A payments ledger's dispute thread keeps its tree in useState and, when an agent posts, calls setTree(addReply(tree, parentId, reply)):
The thread holds 8 comments: Lea's, with replies from Kim and Ola, and Kim's with replies from Max and Nia; Pat's, with Quy's reply; Ray's, with none. Ivo posts a reply to Max.
How many Comment components render, and does Ivo's reply show?
function CommentView({ node }) { return ( <li> {node.author}: {node.body} <ul>{node.replies.map((r) => <Comment key={r.id} node={r} />)}</ul> </li> );} const Comment = memo(CommentView); function addReply(tree, parentId, reply) { return tree.map((n) => (n.id === parentId ? { ...n, replies: [...n.replies, reply] } : n));}Question 5 of 8
the interviewer says: "Proctors discuss a flagged attempt in a thread. Write buildTree(rows), countReplies(node), addReply(tree, parentId, reply) and a Thread({ rows }) component. Rows are { id, parentId, author, body } in any order; parentId is null at the top, and a deleted comment is simply absent. Each comment is an <li> reading 'author: body', with a reply box <input aria-label="Reply to author"> and its replies in a nested <ul>. A reply whose parent is absent shows at the top level. addReply returns a new tree and leaves the old one unchanged." You have 35 minutes, and ExamHall's proctor thread is the use case.
Before you type: say your test list out loud.
Name at least six tests, each with what it does and what it expects.
Question 6 of 8
the interviewer says: "Proctors discuss a flagged attempt in a thread. Write buildTree(rows), countReplies(node), addReply(tree, parentId, reply) and a Thread({ rows }) component. Rows are { id, parentId, author, body } in any order; parentId is null at the top, and a deleted comment is simply absent. Each comment is an <li> reading 'author: body', with a reply box <input aria-label="Reply to author"> and its replies in a nested <ul>. A reply whose parent is absent shows at the top level. addReply returns a new tree and leaves the old one unchanged." You have 35 minutes, and ExamHall's proctor thread is the use case.
Now build it, in 35 minutes, from a blank file that exports buildTree, countReplies, addReply and Thread, with useState and memo in scope.
Question 7 of 8
A shop's product Q&A shows answers newest first, each with a reply box:
Under Pia's question, the page shows Quin's answer, then Rui's. A shopper types "Which size?" into the box under Rui's answer. Then Sol's answer arrives, and the page puts it first under Pia's question.
Which box now holds "Which size?"?
function Answer({ node }) { const [draft, setDraft] = useState(''); return ( <li key={node.id}> {node.author}: {node.body} <input aria-label={`Reply to ${node.author}`} value={draft} onChange={(e) => setDraft(e.target.value)} /> <ul>{node.replies.map((r) => <Answer node={r} />)}</ul> </li> );}Question 8 of 8
A box office's staff thread uses a memo comment and this insert, called as setTree(addReply(tree, parentId, reply)):
The thread holds 6 comments: Sam's, with replies from Tia and Val, and Tia's with Uma's reply; Wes's, with Xan's reply. Yan posts a reply to Uma.
How many Comment components render?
function addReply(nodes, parentId, reply) { return nodes.map((n) => { if (n.id === parentId) return { ...n, replies: [...n.replies, reply] }; const replies = addReply(n.replies, parentId, reply); return replies === n.replies ? n : { ...n, replies }; });}Go deeper
Pro tips
- Build the tree outside React first. Write
buildTree(rows)as a plain function and test it alone before any JSX: a reply before its parent and an orphan catch the two common bugs. When: the first ten minutes of the build (steps 2–5). Verify (RAN,tips.mjs):
(The curriculum row's tip, [SELF], executed here with this lesson's test numbers.)tip 1 NC_IMPL=./drafts/m1-link-while-reading.jsx (links each row as it is read): exit 1 · tests 8 · pass 7 · fail 1 (T2) tip 1 NC_IMPL=./drafts/m2-drop-orphans.jsx (no root for an absent parent): exit 1 · tests 8 · pass 7 · fail 1 (T3) - Run your build against the list before you call it done:
NC_IMPL=./my-thread.jsx node --test thread.test.mjs. When: practising the build at home, against the tests the reference passes (step 12). Verify (RAN,tips.mjs):tip 2 NC_IMPL=./drafts/m0-empty.jsx (renders nothing, exports no functions): exit 1 · tests 8 · pass 0 · fail 8
Source
Docs change, so their claims are labelled as of 2026-10-12; every behaviour this lesson grades is executed.
- Front End Interview Handbook, Front end user interface coding interviews (the curriculum row's first source), https://www.frontendinterviewhandbook.com/coding/build-front-end-user-interfaces (
feih-ui.txt). It lists UI coding questions; it keys nothing here. As of 2026-10-12. - React, Rendering Lists (the row's second source), https://react.dev/learn/rendering-lists (
react-rendering-lists.txt). "JSX elements directly inside a map() call always need keys!" (step 8). "Keys must be unique among siblings. However, it’s okay to use the same keys for JSX nodes in different arrays." (step 8). As of 2026-10-12. - MDN, Map, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map (
mdn-map.txt). "The specification requires maps to be implemented "that, on average, provide access times that are sublinear on the number of elements in the collection"." (step 2). "Iteration happens in insertion order" (step 6). As of 2026-10-12. - MDN, Optional chaining (?.), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining (
mdn-optional-chaining.txt). "However, this short-circuiting behavior only happens along one continuous "chain" of property accesses.". As of 2026-10-12. - React, memo, https://react.dev/reference/react/memo (
react-memo.txt). "React compares every prop in your component with its previous value using the Object.is comparison." (step 11). As of 2026-10-12. - React, Updating Objects in State, https://react.dev/learn/updating-objects-in-state (
react-updating-objects.txt). "Note that the ... spread syntax is “shallow”—it only copies things one level deep." (step 10). As of 2026-10-12.
Related lessons
Checked against the tools and docs as of 2026-10-12.