AniUI Academy

Build collapsible nested comments

mediumUI ComponentsHigh freq+210 XP

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.
Asked atMetaLinkedInAtlassian

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>
  );
}

5 tests · +210 XP

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.