Skip to content
ilinxa/pro-ui

Comment Thread

alphav0.3.1

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

Category: Data DisplayUpdated: 2026-08-19Created: 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
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

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

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
"use client"; import { useMemo, useState } from "react";import { Card } from "@/components/ui/card";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { CommentThread } from "./comment-thread";import {  DUMMY_FLAT_COMMENTS,  DUMMY_LARGE_THREAD,  DUMMY_NESTED_DEPTH_2,  DUMMY_NESTED_DEPTH_3,  DUMMY_VIEWER,  createDummySubscribe,  generateOlderPage,} from "./dummy-data"; function ConsoleSink({ tag, payload }: { tag: string; payload: unknown }) {  // Demo-only no-op log helper.  if (typeof console !== "undefined") {    console.log(`[demo:${tag}]`, payload);  }} function FlatTab() {  return (    <CommentThread      comments={DUMMY_FLAT_COMMENTS}      currentUser={DUMMY_VIEWER}      onAddComment={async (content) => {        ConsoleSink({ tag: "add", payload: content });      }}      onLikeComment={(id, liked) =>        ConsoleSink({ tag: "like", payload: { id, liked } })      }      onDeleteComment={(id) =>        ConsoleSink({ tag: "delete", payload: id })      }      onReportComment={(id) =>        ConsoleSink({ tag: "report", payload: id })      }    />  );} function NestedDepth2Tab() {  return (    <CommentThread      comments={DUMMY_NESTED_DEPTH_2}      currentUser={DUMMY_VIEWER}      maxDepth={2}      onAddComment={async (content, parentId) => {        ConsoleSink({ tag: "reply", payload: { content, parentId } });      }}      onLikeComment={(id, liked) =>        ConsoleSink({ tag: "like", payload: { id, liked } })      }    />  );} function NestedDepth3Tab() {  return (    <CommentThread      comments={DUMMY_NESTED_DEPTH_3}      currentUser={DUMMY_VIEWER}      maxDepth={2}      onAddComment={async (content, parentId) => {        ConsoleSink({ tag: "reply", payload: { content, parentId } });      }}    />  );} function PaginatedTab() {  return (    <CommentThread      comments={DUMMY_LARGE_THREAD}      currentUser={DUMMY_VIEWER}      pageSize={10}      onAddComment={async (content) => {        ConsoleSink({ tag: "add", payload: content });      }}      onLoadMore={async (page) => {        await new Promise((resolve) => setTimeout(resolve, 500));        return generateOlderPage(page);      }}    />  );} function RealtimeTab() {  const subscribe = useMemo(() => createDummySubscribe(), []);  return (    <CommentThread      comments={DUMMY_FLAT_COMMENTS}      currentUser={DUMMY_VIEWER}      subscribe={subscribe}      onSubscribeDelta={(d) => ConsoleSink({ tag: "delta", payload: d })}      onAddComment={async (content) => {        ConsoleSink({ tag: "add", payload: content });      }}      onLikeComment={(id, liked) =>        ConsoleSink({ tag: "like", payload: { id, liked } })      }    />  );} function DisabledComposerTab() {  return (    <CommentThread      comments={DUMMY_FLAT_COMMENTS}      currentUser={undefined}      composerEmptyState={        <Card className="flex items-center justify-between rounded-md p-3">          <span className="text-sm text-muted-foreground">            Sign in to join the conversation.          </span>          <Button size="sm" variant="default">            Sign in          </Button>        </Card>      }    />  );} function CompactVariantTab() {  return (    <CommentThread      variant="compact"      comments={DUMMY_NESTED_DEPTH_2}      currentUser={DUMMY_VIEWER}      maxDepth={1}      indentPx={16}    />  );} export default function CommentThreadDemo() {  const [tab, setTab] = useState("flat");  return (    <Tabs value={tab} onValueChange={setTab} className="w-full">      <SwipeTabsList>        <TabsTrigger value="flat">Flat</TabsTrigger>        <TabsTrigger value="depth2">Nested d2</TabsTrigger>        <TabsTrigger value="depth3">Nested d3</TabsTrigger>        <TabsTrigger value="paginated">Paginated</TabsTrigger>        <TabsTrigger value="realtime">Realtime</TabsTrigger>        <TabsTrigger value="disabled">No user</TabsTrigger>        <TabsTrigger value="compact">Compact</TabsTrigger>      </SwipeTabsList>       <TabsContent value="flat" className="mt-4">        <FlatTab />      </TabsContent>      <TabsContent value="depth2" className="mt-4">        <NestedDepth2Tab />      </TabsContent>      <TabsContent value="depth3" className="mt-4">        <NestedDepth3Tab />      </TabsContent>      <TabsContent value="paginated" className="mt-4">        <PaginatedTab />      </TabsContent>      <TabsContent value="realtime" className="mt-4">        <RealtimeTab />      </TabsContent>      <TabsContent value="disabled" className="mt-4">        <DisabledComposerTab />      </TabsContent>      <TabsContent value="compact" className="mt-4">        <CompactVariantTab />      </TabsContent>    </Tabs>  );} 

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

  • v0.3.1 — the `renderComposer` slot now receives real helpers: `setValue` / `submit` / `cancel` were empty-bodied stubs, so a custom composer could not set a value, send, or clear
  • 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