ESC

Type to search the knowledge base.

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)

  1. Nested vs flat + parentId — flat is easier to update; nested is easier to render
  2. Normalize byId + childrenMap is the production choice
  3. Depth cap for indent + “Continue thread” link
  4. MVP — render recursive + reply insert; then collapse
  5. 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

  1. Mutating nested objects when inserting replies
  2. Orphan nodes when parentId missing from map
  3. Infinite recursion if bad data has cycles — guard with visited set in production
  4. Uncontrolled depth indent pushing content off-screen
  5. Multiple open reply composers — usually allow one replyTo id

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.

Further reading