Skip to content
ilinxa/pro-ui

Comment Thread

alphav0.3.0

Recursive comment thread with composer, optimistic add, like, and delete, inline expansion past max depth, and realtime subscription hooks.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/comment-thread

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/comment-thread-fixtures

Preview

  • MS
    Mira Solano@mira

    This is exactly the kind of post I needed today. Saving for the weekend read.

    2m
  • TZ
    Theo Zarrin@theoz

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

    18m
  • IP
    Ines Park@ines

    Bookmarked. Thanks for sharing — fixed a typo here, the original phrasing was misleading.

    2h (edited)
  • SA
    Sina Aytaç@sina

    (my own comment — kebab shows Delete)

    5h
  • LO
    Lev Ortega@lev

    Following.

    1d
  • HS
    Hana Sato@hana

    I 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
SA

Demo source

demo.tsxtsx

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 via renderViewReplies for navigate-to-detail mode.
  • currentUser absent → bottom composer hidden. Render a sign-in CTA via composerEmptyState.
  • Default kebab's "Delete" only shows on the viewer's own comments. Wire commentActions for moderator semantics.
  • v0.2.0 — comment.edited renders an (edited) suffix; the realtime { kind: "edited" } delta now also flips this flag. The thread still does not surface an Edit affordance — wire it via commentActions.
  • 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, and renderComposer are 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)

Tags

comment-threadsocialcommentsthreadrealtimecomposerrecursiveexpandable

Dependencies

shadcn primitives: avatar, button, dropdown-menu, textarea
npm peer deps: lucide-react@^1.11.0
internal: expandable-text, engagement-bar