Skip to content
Intermediate65 min build target6 min guide

Nested comments

A threaded comment section supporting arbitrary reply depth, collapsing, and inline editing.

Nested comments interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 65-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Render comments as a tree: each comment can have any number of replies, at any depth.
  • Reply to any comment via an inline form; new replies appear nested under their parent immediately.
  • Collapse/expand a thread, hiding its descendants and showing a reply count.
  • Edit and delete a comment the current user authored.
  • Sort top-level threads by newest or most replies.

EDGE CASES

  • Deleting a comment with existing replies: replace its content with a 'deleted' placeholder rather than removing the whole subtree.
  • Very deep threads should cap visual indentation (flattening further nesting) so the layout doesn't break on mobile.
  • Submitting an empty reply should be prevented client-side with a clear message.
  • Optimistically added replies must reconcile correctly if the server ultimately returns a different id.

ACCESSIBILITY

  • Each comment's reply/edit/delete controls are reachable via keyboard in a sensible tab order relative to nesting.
  • Collapse/expand toggles use aria-expanded and a clear accessible name (e.g. 'Hide 4 replies').
  • Focus moves to the new reply's textarea when the reply form opens.

SUGGESTED APPROACH

  • Model as a flat map of comments keyed by id, each storing its parentId, plus a derived tree built once for rendering — flat storage makes edit/delete/insert O(1) lookups instead of recursive tree surgery.
  • Render recursively: a Comment component renders its own body then maps its children ids through itself.
  • Handle 'reply' by inserting a new node into the flat map with the right parentId and re-deriving the affected branch of the tree.
  • Cap rendered indentation at a maximum depth constant, continuing to nest logically past that point without visually compounding margin.

EVALUATION RUBRIC

  • Arbitrary-depth nesting renders and updates correctly (add/edit/delete) without breaking sibling threads.
  • Deleted comments with replies preserve the subtree via a placeholder.
  • Collapse/expand state is scoped per-thread and doesn't leak across threads.
  • Deep threads remain usable on a narrow viewport.

01Understand the product before coding

Learning goals

  • Render comments as a tree: each comment can have any number of replies, at any depth.
  • Reply to any comment via an inline form; new replies appear nested under their parent immediately.
  • Collapse/expand a thread, hiding its descendants and showing a reply count.
  • Edit and delete a comment the current user authored.

Decisions to state aloud

  • Deleting a comment with existing replies: replace its content with a 'deleted' placeholder rather than removing the whole subtree.
  • Very deep threads should cap visual indentation (flattening further nesting) so the layout doesn't break on mobile.
  • Submitting an empty reply should be prevented client-side with a clear message.
  • Optimistically added replies must reconcile correctly if the server ultimately returns a different id.

02State model and invariants

Store comments in a normalized map with parentId and ordered childIds. Collapse, selection, and editor drafts are UI state keyed by id. A deleted parent becomes a tombstone when descendants exist, preserving reply structure.

TypeScript
type Comment = {  id: string; parentId: string | null; childIds: string[];  authorId: string; body: string; status: 'published' | 'pending' | 'failed' | 'deleted';};type State = { comments: Record<string, Comment>; rootIds: string[];  collapsed: Set<string>; editingId: string | null; replyingToId: string | null; };

03Component architecture

  1. 01ThreadList owns top-level ordering.
  2. 02CommentNode recursively renders one record and its children.
  3. 03CommentComposer handles reply draft and optimistic mutation.
  4. 04ThreadToggle derives descendant reply count.
  5. 05LiveRegion announces reply, edit, delete, and failure outcomes.

04Reference implementation walkthrough

Step 1

Normalize incoming threads

Validate ids and parent links, build child arrays once, and prevent cycles. Keep server order explicit rather than depending on object property order.

TypeScript
function addReply(state: State, reply: Comment): State {  const parent = state.comments[reply.parentId!];  return { ...state, comments: { ...state.comments,    [reply.id]: reply,    [parent.id]: { ...parent, childIds: [...parent.childIds, reply.id] }  }};}

Step 2

Reconcile optimistic ids

Create a client id and pending comment immediately. On success replace the record key and every parent child reference in one transaction; on failure retain the draft with a retryable failed status.

TypeScript
function replaceId(state: State, tempId: string, saved: Comment) {  const temp = state.comments[tempId];  const parent = state.comments[temp.parentId!];  const childIds = parent.childIds.map((id) => id === tempId ? saved.id : id);  // replace record and parent together; never append a second copy  return patchRecords(state, tempId, saved, { ...parent, childIds });}

Step 3

Preserve descendants on delete

If a comment has children, replace body and author presentation with a deleted tombstone. Remove a leaf only after choosing the next logical focus destination.

Step 4

Cap visual indentation

Logical nesting remains in the DOM, but CSS indentation stops after a small depth and a continuation treatment preserves readability on mobile.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a comment thread where you can reply at any depth, the reply shows instantly before the server confirms, a failed reply can be retried, and deleting a comment that has replies keeps the replies. The one big idea: store every comment flat by id with a list of child ids, render it recursively, and treat the temporary id you invent for an optimistic reply as something you later swap for the real one in exactly one place.

CommentThread.tsx
import { useReducer, useState } from "react";
type Comment = {  id: string;  parentId: string | null;  childIds: string[];  author: string;  body: string;  status: "published" | "pending" | "failed" | "deleted";};type State = { comments: Record<string, Comment>; rootIds: string[] };type Action =  | { type: "addReply"; tempId: string; parentId: string | null; body: string }  | { type: "retry"; id: string }  | { type: "saved"; tempId: string; saved: Comment }  | { type: "failed"; id: string }  | { type: "delete"; id: string };
function reducer(state: State, action: Action): State {  switch (action.type) {    case "addReply": {      const reply: Comment = {        id: action.tempId, parentId: action.parentId, childIds: [],        author: "You", body: action.body, status: "pending",      };      const comments = { ...state.comments, [action.tempId]: reply };      if (!action.parentId) return { ...state, comments, rootIds: [...state.rootIds, action.tempId] };      const parent = comments[action.parentId];      comments[action.parentId] = { ...parent, childIds: [...parent.childIds, action.tempId] };      return { ...state, comments };    }    case "retry": {      const c = state.comments[action.id];      return { ...state, comments: { ...state.comments, [action.id]: { ...c, status: "pending" } } };    }    case "failed": {      const c = state.comments[action.id];      return { ...state, comments: { ...state.comments, [action.id]: { ...c, status: "failed" } } };    }    case "saved": {      const temp = state.comments[action.tempId];      const comments = { ...state.comments };      delete comments[action.tempId];      comments[action.saved.id] = { ...action.saved, childIds: temp.childIds, status: "published" };      if (!temp.parentId) {        return { ...state, comments, rootIds: state.rootIds.map((id) => id === action.tempId ? action.saved.id : id) };      }      const parent = comments[temp.parentId];      comments[temp.parentId] = {        ...parent,        childIds: parent.childIds.map((id) => id === action.tempId ? action.saved.id : id),      };      return { ...state, comments };    }    case "delete": {      const node = state.comments[action.id];      if (node.childIds.length > 0) {        // has replies: keep the node as a tombstone so replies are not orphaned        return {          ...state,          comments: { ...state.comments, [action.id]: { ...node, author: "", body: "", status: "deleted" } },        };      }      const comments = { ...state.comments };      delete comments[action.id];      if (!node.parentId) return { ...state, comments, rootIds: state.rootIds.filter((id) => id !== action.id) };      const parent = comments[node.parentId];      comments[node.parentId] = { ...parent, childIds: parent.childIds.filter((id) => id !== action.id) };      return { ...state, comments };    }  }}
const MAX_INDENT = 5;
export function CommentThread({ initial, save }: {  initial: State;  save: (parentId: string | null, body: string) => Promise<Comment>;}) {  const [state, dispatch] = useReducer(reducer, initial);
  async function post(parentId: string | null, body: string, existingId?: string) {    const id = existingId ?? "temp-" + crypto.randomUUID();    dispatch(existingId ? { type: "retry", id } : { type: "addReply", tempId: id, parentId, body });    try {      const saved = await save(parentId, body);      dispatch({ type: "saved", tempId: id, saved });    } catch {      dispatch({ type: "failed", id });    }  }
  return (    <div>      {state.rootIds.map((id) => (        <Node key={id} id={id} depth={0} comments={state.comments}          onReply={post} onDelete={(cid) => dispatch({ type: "delete", id: cid })} />      ))}      <ReplyBox label="Add a comment" onSubmit={(body) => post(null, body)} />    </div>  );}
function Node({ id, depth, comments, onReply, onDelete }: {  id: string; depth: number; comments: Record<string, Comment>;  onReply: (parentId: string | null, body: string, existingId?: string) => void;  onDelete: (id: string) => void;}) {  const comment = comments[id];  const [replying, setReplying] = useState(false);  const indent = Math.min(depth, MAX_INDENT) * 20;
  return (    <article style={{ marginLeft: indent }}>      {comment.status === "deleted" ? (        <p><em>[deleted]</em></p>      ) : (        <>          <p><strong>{comment.author}</strong>{comment.status === "pending" && " (sending\u2026)"}</p>          <p>{comment.body}</p>          {comment.status === "failed" && (            <p role="alert">              Could not post.{" "}              <button type="button" onClick={() => onReply(comment.parentId, comment.body, id)}>Retry</button>            </p>          )}          <button type="button" onClick={() => setReplying((v) => !v)}>Reply</button>          <button type="button" onClick={() => onDelete(id)}>Delete</button>        </>      )}      {replying && (        <ReplyBox label="Reply" onSubmit={(body) => { onReply(id, body); setReplying(false); }} />      )}      {comment.childIds.map((childId) => (        <Node key={childId} id={childId} depth={depth + 1} comments={comments}          onReply={onReply} onDelete={onDelete} />      ))}    </article>  );}
function ReplyBox({ label, onSubmit }: { label: string; onSubmit: (body: string) => void }) {  const [value, setValue] = useState("");  return (    <form onSubmit={(e) => { e.preventDefault(); if (value.trim()) { onSubmit(value.trim()); setValue(""); } }}>      <label>{label}<textarea value={value} onChange={(e) => setValue(e.target.value)} /></label>      <button type="submit">Post</button>    </form>  );}

How each part works

Comments are flat, keyed by id

state.comments is { id -> comment } and each comment holds childIds, an ordered list of its direct replies. rootIds is the top-level order. Adding or editing one comment is a targeted object update, not a rebuild of a deep tree.

Node renders itself and then its children

The Node component draws one comment, then maps over childIds and renders a Node for each, one level deeper. Recursion here is safe because the data is a real tree (every child has exactly one parent) and depth only feeds an indentation number.

An optimistic reply uses a temporary id

When you post, we invent a temp- id and add the comment immediately with status 'pending', so it appears at once. The real save runs in the background. This temp id is a placeholder we will replace.

'saved' swaps the temp id in exactly one place

When the server returns the real comment, the reducer deletes the temp record, adds the real one (carrying over any childIds the temp already collected), and repoints the parent's childIds (or rootIds) entry from the temp id to the real id — one atomic update, so a second copy is never appended.

Failure keeps the draft and offers Retry

On error the comment flips to status 'failed' and stays on screen with its text. Retry calls post again with the same existing id, which dispatches 'retry' (back to pending) and re-sends — it does not create another comment.

Delete with replies leaves a tombstone

If the comment has children, deleting it just blanks the author and body and sets status 'deleted', so the replies underneath stay connected. Only a comment with no replies is actually removed and unlinked from its parent.

Indentation is capped

indent = Math.min(depth, MAX_INDENT) * 20, so after five levels the visual nesting stops growing. The DOM hierarchy and any accessible labels still reflect the true depth; only the left margin is clamped so deep threads stay readable on a phone.

Why this is correct

  • Flat id-keyed storage with childIds lists keeps edits and optimistic swaps to small, targeted updates.
  • The temporary id for an optimistic reply must be replaced in one atomic reducer step that also fixes the parent reference, or you get a duplicate.
  • A failed reply stays visible with its text and retries in place using the same id; it never spawns a second comment.
  • Deleting a comment that has replies converts it to a tombstone so the replies are not orphaned.
  • Visual indentation is clamped past a small depth while the logical tree stays intact.

05Testing strategy

Critical behavior

  • Arbitrary-depth nesting renders and updates correctly (add/edit/delete) without breaking sibling threads.
  • Deleted comments with replies preserve the subtree via a placeholder.
  • Collapse/expand state is scoped per-thread and doesn't leak across threads.
  • Deep threads remain usable on a narrow viewport.

Failure and boundary cases

  • Deleting a comment with existing replies: replace its content with a 'deleted' placeholder rather than removing the whole subtree.
  • Very deep threads should cap visual indentation (flattening further nesting) so the layout doesn't break on mobile.
  • Submitting an empty reply should be prevented client-side with a clear message.
  • Optimistically added replies must reconcile correctly if the server ultimately returns a different id.

Accessibility

  • Each comment's reply/edit/delete controls are reachable via keyboard in a sensible tab order relative to nesting.
  • Collapse/expand toggles use aria-expanded and a clear accessible name (e.g. 'Hide 4 replies').
  • Focus moves to the new reply's textarea when the reply form opens.

06Performance and production hardening

  • Paginate top-level threads and lazy-load large reply branches.
  • Memoize nodes by normalized record identity.
  • Maintain descendant counts instead of walking huge subtrees every render.
  • Virtualize only top-level or flattened visible rows with stable focus restoration.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

Edits and optimistic reconciliation become direct record updates while parent-child order remains explicit and recursive rendering stays simple.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 65 minutes, then self-review against the rubric before moving on.

Back to all briefs →