This is exactly the kind of post I needed today. Saving for the weekend read.
Comment Thread
alphav0.3.0Recursive comment thread with composer, optimistic add, like, and delete, inline expansion past max depth, and realtime subscription hooks.
Context
Fifth ship in the 8-component social-posts-system arc and the second cross-folder import in pro-ui (after media-carousel → video-player). Component is always-uncontrolled — `comments` prop is initial state on mount only; subsequent prop changes are IGNORED. Use the imperative handle's `reset(next)` or `dispatch(action)` to push external updates. Realtime via Subscribe<CommentDelta> matches engagement-bar's shape one-to-one (single mental model). Per-row engagement-bar is always controlled by the thread reducer to keep state coherent under realtime + optimistic flow. No framer-motion, no react-textarea-autosize peer dep, no date-fns peer dep. Migration origin: kasder kas-social-front-v0 PostEngagementPanel.tsx (468 LOC), CommentItem sub-component lines 413–467.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/comment-threadAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/comment-thread-fixturesPreview
MS @mira2mTZ @theozHot take: most of what's framed as a system-design problem is actually a state-management problem dressed up. The boundaries we draw between services are usually load-bearing for our state model, not the other way around. Curious what others think about this — particularly in the context of the analytics-pipeline rewrite we shipped last quarter.
18mIP @inesBookmarked. Thanks for sharing — fixed a typo here, the original phrasing was misleading.
2h (edited)SA @sina(my own comment — kebab shows Delete)
5hLO @levFollowing.
1dHS @hanaI think I disagree with point 3 — but it depends on what you mean by 'eventual consistency' here. Are you describing the storage layer or the read model?
3d
Demo source
Usage
When to use
Reach for CommentThread when you need a recursive comment panel under any content surface — posts, news articles, events, product reviews, document annotations. It composes expandable-text for long bodies and engagement-bar variant="compact" for the per-row like action.
Footgun: the `comments` prop is mount-only
Component takes comments as initial state on mount only. Subsequent prop reference changes are ignored. To push external updates, use the imperative handle's reset(next) or dispatch(action):
const ref = useRef<CommentThreadHandle>(null);
useEffect(() => {
ref.current?.reset(externalComments);
}, [externalComments]);
<CommentThread ref={ref} comments={externalComments} />Basic example
import { CommentThread } from "@/components/comment-thread";
export function Example() {
return (
<CommentThread
comments={post.comments}
currentUser={{ id: viewer.id, name: viewer.name, avatar: viewer.avatarUrl }}
onAddComment={async (content, parentId) => {
const created = await api.addComment(post.id, { content, parentId });
return created; // component swaps temp comment for real one
}}
onLikeComment={(id, nextLiked) => api.likeComment(id, nextLiked)}
onDeleteComment={(id) => api.deleteComment(id)}
onReportComment={(id) => openReportDialog(id)}
/>
);
}Realtime via subscribe
const subscribe = useCallback<Subscribe<CommentDelta>>(
(handler) => channel.on("comment", handler),
[channel],
);
<CommentThread
comments={post.initialComments}
currentUser={viewer}
subscribe={subscribe}
onSubscribeDelta={(d) => analytics.track("comment-delta", d)}
/>Hosts must memoize subscribe via useCallback — identity changes trigger a clean teardown + re-call. Same contract as engagement-bar.
Custom kebab actions
<CommentThread
comments={comments}
currentUser={viewer}
commentActions={(comment, { isOwn }) => [
isOwn && { label: "Pin", onClick: () => api.pinComment(comment.id) },
isOwn && { label: "Delete", destructive: true, onClick: () => api.deleteComment(comment.id) },
!isOwn && { label: "Block author", onClick: () => api.block(comment.author.id) },
// v0.2.0 — explicit section divider above this item
{ label: "Mark spam", separatorBefore: true, onClick: () => api.flagSpam(comment.id) },
{ label: "Report", onClick: () => openReportDialog(comment.id) },
].filter(Boolean) as CommentMenuItem[]}
/>v0.2.0 — edited badge
Set comment.edited = true on first paint when your backend reports the comment was edited; the row renders an (edited) suffix after the timestamp. The realtime { kind: "edited" } delta also flips edited:true, so first-paint and post-realtime UI behave identically. Override the suffix copy via labels.editedSuffix:
<CommentThread
comments={comments.map((c) => ({ ...c, edited: c.serverEditedAt != null }))}
labels={{ editedSuffix: "(düzenlendi)" }} // i18n override
/>Standalone composer
CommentComposer ships standalone for hosts that want the composer without the thread (article-page hero CTAs):
import { CommentComposer } from "@/components/comment-thread";
<CommentComposer
currentUser={viewer}
placeholder="Share your thoughts…"
onSubmit={async (content) => api.addArticleComment(article.id, content)}
/>Notes
maxDepthdefaults to 2; past it, "view N replies" inline-expands. Override the link viarenderViewRepliesfor navigate-to-detail mode.currentUserabsent → bottom composer hidden. Render a sign-in CTA viacomposerEmptyState.- Default kebab's "Delete" only shows on the viewer's own comments. Wire
commentActionsfor moderator semantics. - v0.2.0 —
comment.editedrenders an(edited)suffix; the realtime{ kind: "edited" }delta now also flips this flag. The thread still does not surface an Edit affordance — wire it viacommentActions. - v0.2.0 —
CommentMenuItem.separatorBeforedraws a divider above the item in the kebab. Useful for host-grouped sections (e.g. post-card's moderator block). renderNode,renderViewReplies, andrenderComposerare full-takeover slots.
Features
- Recursive Comment[] with optional `replies?` per node — depth-aware indentation
- maxDepth default 2; past it, inline-expand 'view N replies' (slot-overridable)
- Autosize composer (roll-our-own ~25-LOC hook; no react-textarea-autosize)
- Keyboard ergonomics — Enter submits, Shift+Enter newline, Escape cancels
- Optimistic add (head insertion top-level; tail insertion replies)
- Optimistic like flip via thread reducer (per-row engagement-bar always-controlled)
- Optimistic delete + revert via host's comments prop or realtime delta
- Realtime via Subscribe<CommentDelta> — added / edited / removed / liked
- onSubscribeDelta callback fires for every delta regardless of mode
- v0.2.0 — `Comment.edited` first-paint flag + `(edited)` suffix render after timestamp; realtime `{ kind: "edited" }` delta also flips `edited:true` so first-paint and post-edit UI behave identically
- v0.2.0 — `CommentMenuItem.separatorBefore` opt-in divider above any kebab item (used by post-card's moderator section; reusable for host-grouped kebabs)
- v0.2.1 — F-cross-13 + F-S1 cleanup: `<CommentKebab>` drops `<Button asChild>` wrapper (shadcn CLI rewrites `asChild` to `render={…}` at install-time which breaks consumers on Radix); render trigger directly as `<button>` via `buttonVariants(…)`. Cross-procomp imports in `comment-node.tsx` converted to relative + specific-file paths. Zero public-API change.
- Pagination — onLoadMore(page) appends; pageSize default 10
- Inline reply composer per row (kasder UX); single composer in DOM at a time
- Default kebab — Delete (own only) + Report (when wired); commentActions slot for full takeover
- renderNode / renderViewReplies / renderComposer full-takeover slots
- composerEmptyState slot for sign-in CTA when currentUser absent
- Imperative handle — focusComposer / openReply / getCurrentComments / reset / dispatch
- commentReducer + useCommentState publicly exported (external state coordination)
- useAutosizeTextarea publicly exported (composer behaviour without the thread)
- CommentComposer publicly exported standalone (article-page hero CTAs)
- i18n via 12-key labels object with English defaults
- a11y — role=article + aria-labelledby; aria-pressed on like; useId() per node
- Touch-friendly kebab via pointer-coarse:opacity-100 (Tailwind v4)