Skip to content

About

Real-time nested comment threads (Reddit/YouTube style) over WebSocket — resolves out-of-order arrival where a child can land before its parent, while new comments stream in live. React 19 + TypeScript + Zustand, Express + ws.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nested Comments Tree

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.


Tech stack

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

Quick start

You need two terminals (backend + frontend). Bun is recommended; npm works too.

1. Backend

cd server
bun install
bun run dev          # → http://localhost:4000  (WebSocket path: /ws)

2. Frontend

cd client
bun install
bun run dev          # → http://localhost:5173

Open 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_URL in client/.env (defaults to ws://localhost:4000/ws).

Production build

cd client && bun run build      # type-checks + bundles to client/dist
cd server && bun run build      # tsc → server/dist

Project structure

.
├── 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

Architecture overview

The data structure (the crux)

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.

How comments attach when the parent arrives later

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 parentId but 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.

State management & re-render optimization

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:

  1. Selector subscriptions — each node subscribes to the minimum slice it needs.
  2. React.memo + primitive props (id, depth) — React bails out of unchanged subtrees with no custom comparator.
  3. Sorting is memoized on the array reference (useMemo) — re-sorts only when that sibling list changes, never on an unrelated live insert.

Expand / collapse

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).

Real-time transport & offline handling

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 connecting handshake (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.

Feature → implementation map

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

Search design decision (explained, as the brief asks)

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.

Comment ordering

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).


Assumptions & trade-offs

  • id is monotonic with creation time. The server assigns every id from one shared counter, so I sort by id for 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.

Edge cases handled

  • 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 usage

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 + childIds data 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.


Scripts

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

About

Real-time nested comment threads (Reddit/YouTube style) over WebSocket — resolves out-of-order arrival where a child can land before its parent, while new comments stream in live. React 19 + TypeScript + Zustand, Express + ws.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages