Build collapsible nested comments
Render a fixed comment thread: two top-level comments, the first of which has a reply, which itself has a reply (three levels deep).
Each comment with replies gets a toggle that collapses or expands its own direct replies — and collapsing must hide everything nested underneath them too, not just the direct children.
Examples
// Initial: 4 comments visible (ids 1, 2, 3, 4).
// Comment 1 has toggle "Hide replies (1)"
// Comment 2 has toggle "Hide replies (1)"
// Comments 3 and 4 have no toggle.
// Click toggle-1
// → only comments 1 and 4 remain (2 and 3 unmount).
// → toggle-1 label flips to "Show replies (1)".
// Click toggle-1 again → all 4 reappear.Collapse hides the whole subtree, not just the direct replies. The count is of DIRECT children only.
function Comment({ comment }) {
const [collapsed, setCollapsed] = useState(false);
const hasReplies = comment.replies.length > 0;
return (
<div data-testid="comment" data-comment-id={comment.id}>
<p>{comment.text}</p>
{hasReplies && (
<button onClick={() => setCollapsed((c) => !c)}>
{collapsed ? "Show" : "Hide"} replies ({comment.replies.length})
</button>
)}
{!collapsed && comment.replies.map((r) =>
<Comment key={r.id} comment={r} />)}
</div>
);
}Local collapse state per node + recursive render = subtree hiding falls out automatically.
What it has to do
- Every comment renders as `<div data-testid="comment" data-comment-id="ID">`.
- A comment with replies has a button `data-testid="toggle-ID"` reading "Hide replies (N)" when expanded and "Show replies (N)" when collapsed, where N is its number of direct replies.
- Collapsing a comment removes it and every one of its descendants from the DOM entirely — not just visually hidden.
- Comments with no replies get no toggle button at all.
Notes
- Keep collapse state local to each `Comment` — a global `Map<id, collapsed>` works too but adds plumbing without a payoff for a static tree.
- A real-world thread needs cycle detection or at least a max depth — a malformed `replies[]` referencing an ancestor would infinite-loop the render.
- Virtualize the top-level list once threads grow large (10K+ visible nodes) — recursive rendering is fine for a page's worth, not a feed's worth.
- Give each toggle an `aria-expanded` and an `aria-controls` pointing to the replies container, so a screen reader announces the collapse state change.
Stuck? The react course covers the ideas this problem is built on.
const COMMENTS = [ { id: 1, text: "First comment", replies: [ { id: 2, text: "Reply to first", replies: [{ id: 3, text: "Reply to reply", replies: [] }], }, ], }, { id: 4, text: "Second comment", replies: [] }, ]; function Comment({ comment }) { // TODO: clicking the toggle button should hide/show this comment's // direct replies (and everything nested inside them), and the label // should read "Hide replies (N)" / "Show replies (N)" where N is the // number of DIRECT replies. const hasReplies = comment.replies.length > 0; return ( <div data-testid="comment" data-comment-id={comment.id} style={{ marginLeft: 16, marginTop: 8 }}> <p>{comment.text}</p> {hasReplies ? ( <button data-testid={`toggle-${comment.id}`}> Hide replies ({comment.replies.length}) </button> ) : null} <div data-testid={`replies-${comment.id}`}> {comment.replies.map((reply) => ( <Comment key={reply.id} comment={reply} /> ))} </div> </div> ); } export default function App() { return ( <div style={{ fontFamily: "sans-serif", padding: 16 }}> {COMMENTS.map((comment) => ( <Comment key={comment.id} comment={comment} /> ))} </div> ); }
- 1.Every comment renders as `<div data-testid="comment" data-comment-id="ID">`.
- 2.A comment with replies has a button `data-testid="toggle-ID"` reading "Hide replies (N)" when expanded and "Show replies (N)" when collapsed, where N is its number of direct replies.
- 3.Collapsing a comment removes it and every one of its descendants from the DOM entirely — not just visually hidden.
- 4.Comments with no replies get no toggle button at all.
5 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
Discussion
Sign in to postNo comments yet. Be the first to say something.