A real-time, infinitely-nested comment thread (Reddit / YouTube style) built with a React + TypeScript frontend and a Node.js (Express + WebSocket) backend.
The hard part of this problem isn't rendering a tree — it's that the API returns comments in arbitrary order, a child can arrive before its parent, and new comments keep streaming in live. The whole design is organised around making that a non-event.
| Layer | Choice | Why |
|---|---|---|
| Frontend | React 19 + Vite + TypeScript | Client-heavy interactive tree; Vite for instant HMR |
| State | Zustand | Selector-based subscriptions → surgical, per-node re-renders |
| UI | shadcn/ui + MagicUI (Tailwind v4) | Owned, copy-in components — real code in the repo, not a black box |
| Backend | Express + ws |
The "new comment every 2–5s" requirement is a push stream; a real WebSocket beats a fake timer and makes the offline requirement authentic |
| Tooling | Bun | Fast installs / dev server |
You need two terminals (backend + frontend). Bun is recommended; npm works too.
cd server
bun install
bun run dev # → http://localhost:4000 (WebSocket path: /ws)cd client
bun install
bun run dev # → http://localhost:5173Open http://localhost:5173. The client connects to ws://localhost:4000/ws, receives an initial (deliberately shuffled) batch of ~100 comments, and then a new comment every 2–5 seconds.
Custom backend URL: set
VITE_WS_URLinclient/.env(defaults tows://localhost:4000/ws).
cd client && bun run build # type-checks + bundles to client/dist
cd server && bun run build # tsc → server/dist.
├── server/ # Express + WebSocket "API"
│ └── src/
│ ├── index.ts # HTTP + WS bootstrap
│ ├── ws-hub.ts # owns comment list, validates input, broadcasts
│ ├── comment-generator.ts # seed batch + live feed + shared id counter
│ └── types.ts # Comment + message contract
│
└── client/
└── src/
├── store/useCommentStore.ts # ⭐ normalized tree + the core insert
├── hooks/
│ ├── useCommentSocket.ts # WS lifecycle (reconnect, backoff)
│ └── useOnlineStatus.ts # browser online/offline
├── lib/search.ts # match + ancestor auto-expand
├── components/
│ ├── CommentTree.tsx # root list (newest-first)
│ ├── CommentNode.tsx # ⭐ ONE node — the re-render unit
│ ├── ReplyBox.tsx / SearchBar.tsx / Highlight.tsx
│ ├── LiveStatus.tsx / OfflineBanner.tsx / ErrorBoundary.tsx
│ └── ui/ # shadcn/ui + MagicUI components
└── App.tsx
State is normalized — data and structure are kept separate:
byId: Record<number, Comment> // id → the comment's DATA
childIds: Record<string, number[]> // parentId → its children's ids ("root" = top level)This is the same idea as Redux's createEntityAdapter: a flat lookup plus an adjacency map, instead of a deeply nested object that's painful to update immutably.
This is the single most important decision, and it's why ordering doesn't matter. The entire insert is:
addComment(c) {
if (byId[c.id]) return; // idempotent — re-delivery is a no-op
byId[c.id] = c;
const key = c.parentId ?? "root";
childIds[key] = [...(childIds[key] ?? []), c.id]; // file under parentId, seen or not
}A comment is filed under childIds[parentId] whether or not that parent exists yet. Rendering only ever walks down from "root", so:
- An orphan (parent not here yet) is filed under its
parentIdbut is simply unreachable from the root — so it isn't rendered. - The moment its parent arrives, the parent becomes reachable, its render reads
childIds[parent.id], and the waiting child appears automatically — no reconciliation pass, no re-sorting, no orphan buffer.
The same addComment handles all three cases identically: the initial shuffled batch, live WebSocket pushes, and user replies. Insertion is O(1) and order-independent.
Every CommentNode reads only its own slice of state through Zustand selectors:
const comment = useCommentStore(s => s.byId[id]);
const childIds = useCommentStore(s => s.childIds[String(id)]);
const collapsed = useCommentStore(s => s.collapsed.has(id));So when a new comment inserts under node X, only childIds[X] changes → only node X re-renders. Siblings, cousins, and ancestors are untouched. Three levers keep it smooth at 500+ comments:
- Selector subscriptions — each node subscribes to the minimum slice it needs.
React.memo+ primitive props (id,depth) — React bails out of unchanged subtrees with no custom comparator.- Sorting is memoized on the array reference (
useMemo) — re-sorts only when that sibling list changes, never on an unrelated live insert.
Collapsed state lives outside the tree as a Set<number> of collapsed ids — not a boolean on each node. This means collapsing branch A can't affect branch B, and expansion state survives live updates completely (inserting a comment never touches the set).
The backend is a WebSocket hub that owns the comment list and broadcasts every insert to all clients. Because the live feed is the socket, the "must warn when offline" requirement becomes authentic:
- Connection status is a 3-state enum:
connecting → online → offline. - The offline banner shows only when the browser is offline or the socket dropped after connecting — never during the initial
connectinghandshake (so a fresh page load doesn't flash a false warning). - Reconnect uses exponential backoff (1→2→4→8→10s) instead of hammering a downed server.
| Requirement | Where / how |
|---|---|
| Unlimited-depth tree | Recursive CommentNode + childIds adjacency map — no depth hardcoded, no TreeView lib |
| Out-of-order / child-before-parent | childIds[parentId] + reachable-from-root rendering (see above) |
| Independent expand / collapse, persists | Set<number> of collapsed ids, external to the tree |
| Live updates, no full rebuild | WS push → addComment → selector re-renders only the touched subtree |
| Reply to any comment | Same addComment; server assigns the real id and echoes it back to everyone |
| Search + highlight | lib/search.ts collects matches + auto-expands their ancestors; <Highlight> wraps matched text |
| 500+ smooth | React.memo + per-node selectors + memoized sort |
| Offline warning | connectionStatus enum + navigator.onLine |
A match hidden inside a collapsed branch is a failed search, so searching auto-expands the ancestors of every match and highlights the matched substring. Non-matching branches are left as the user set them.
- Trade-off: this can override a branch the user manually collapsed while a search is active. I accept that because findability is the primary goal of search; the underlying collapsed state is untouched and reverts the instant the query is cleared.
- Alternative considered: a "N matches hidden in collapsed branches" chip that lets the user opt in instead of forcing expansion — better for preserving layout, worse for recall. Left out to keep the search path simple.
Root comments render newest-first (like Facebook / Instagram / YouTube) so a comment you just posted appears at the top without scrolling. Replies inside a thread render oldest-first so a conversation reads top-to-bottom in the order it happened. Ordering is by id (a single incrementing counter, so higher id = posted later — a reliable "newest" with no timestamp tiebreaker).
idis monotonic with creation time. The server assigns every id from one shared counter, so I sort byidfor chronological order instead of parsing timestamps (avoids ties when many comments share a millisecond).- The server is the source of truth for ids. Replies are not inserted optimistically — the client sends the text, the server assigns the id and broadcasts it back. This guarantees the client never invents an id that later collides with a server-generated one.
- Relative timestamps don't tick live. "2m ago" updates on the next re-render, not on a global interval — a 1s interval would re-render all 500+ nodes and defeat the whole optimization. Deliberate.
- No virtualization (yet). Memoization solves re-render cost. For raw DOM-node count beyond ~1000, windowing (
@tanstack/react-virtual) over a flattened visible list is the documented next step. - Search is not debounced — O(n) per keystroke is fine at ≤1000 comments.
- Child arriving before its parent (initial batch and live — the server intentionally emits some orphan pairs to prove it).
- Duplicate / re-delivered comments are a no-op (idempotent insert), which matters because every reconnect re-sends the full snapshot.
- Malformed / empty / oversized input is rejected server-side (never trust the client).
- Reconnection with exponential backoff; stale-socket events are ignored.
- A render error is contained by an error boundary instead of white-screening the app.
AI (Claude) was used as a pair-programmer. To be specific about the split:
Where AI helped:
- Scaffolding boilerplate — Vite setup, shadcn/ui + MagicUI wiring, Tailwind config, component skeletons.
- Drafting the WebSocket lifecycle boilerplate and CSS.
- Reviewing the code for edge cases (it helped surface the duplicate-key-on-reconnect case during testing).
What I designed / decided myself:
- The normalized
byId+childIdsdata structure and the order-independent insert — the core of the solution. - The re-render optimization strategy (per-node selectors + memoized sort) and why it satisfies the 500+ requirement.
- The id-collision fix (single shared server counter) and the idempotent insert guard.
- The search UX decision (auto-expand ancestors) and the ordering decision (roots newest-first, replies oldest-first).
- The connection-status model that fixed the false-offline flash, and server-side input validation.
Code comments throughout (in useCommentStore.ts, ws-hub.ts, useCommentSocket.ts, CommentNode.tsx) are written in my own words to explain these decisions.
| Location | Command | Does |
|---|---|---|
server/ |
bun run dev |
Start the WS + HTTP server (watch mode) |
server/ |
bun run build |
Type-check + compile to dist/ |
client/ |
bun run dev |
Start the Vite dev server |
client/ |
bun run build |
Type-check + production bundle |