Comment Thread
ProThreaded comment display with depth-limited nested replies, an inline reply box, emoji reactions, relative timestamps and an avatar/initials fallback.
Complexity tier: Interactive The primitive bar, plus declared async states, a keyboard model, focus restoration and undo for destructive actions.
import { SvCommentComponent } from '@styloviz-pro/display';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Read-only thread
ProEditable own comments
ProPersisting a reaction, and putting it back
ProThe pill lights up and its count moves on press — it does not wait for you, because a reaction that needs a round trip reads as a dead button. That leaves the component holding the only copy of the pre-toggle value, so the event carries rollback(). Bind busyIds: it locks the comment while the write is in flight, which is what keeps rollback() unambiguous. Send requestKey so a retried request cannot count the same reaction twice.
Product reviews (rating + verified + helpful)
Provariant="review" renders each entry as a product review: a star rating, a verified-purchase badge and a helpful-vote button.
Deleting a comment that has replies
ProDropping the node drops the replies with it. That is fine for a leaf and it is data loss for a parent, because the author asked for their own words to go and not for everyone else's. Mark the parent deleted and leave it in the array: the component renders a tombstone in its place — content, identity, reactions and every action withheld, timestamp kept so the conversation still reads in order — and the replies render underneath exactly as before.
Reporting a failure on the comment that caused it
Proerror renders once, at the top. In a thread of forty that puts the message a screenful away from the button that produced it, and a second failure overwrites the first. commentErrors is keyed by comment id, renders beside the comment, and reaches nested replies. Key a failed reply by the parentId — the reply itself was never posted and has no id yet.
When to use
- Displaying discussion threads with nested replies and reactions.
- You want inline reply, emoji reactions and relative timestamps out of the box.
- For inline @-references inside a single field, use the Mention input.
Async states
- loading
- empty
- error
- retry
Item identity & owned state
- Restorable state
- busyIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Server writes
- Writes
- react
Each payload carries a
requestKey— send it with the request so a retry cannot apply the same change twice.- Rollback
- Call
event.rollback()when the save fails — the change is already on screen, and this is what puts it back. - While saving
- Bind
busyIds— with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands. - On failure
- Pass the failure to
errorto show it in place — without taking away what the user was working on.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| comments | Comment[] | [] | Array of top-level comments with optional nested replies. |
| currentUserId | string | '' | ID of the currently logged-in user (for "edit/delete own" logic). |
| showReplyBox | boolean | true | Show inline reply box below each comment. |
| maxDepth | number | 3 | Maximum nesting depth. |
| variant | 'thread' | 'review' | 'thread' | Display mode: threaded discussion, or a product-review list. |
| busyIds | readonly string[] | ReadonlySet<string> | [] | Ids of actions with a request in flight. Those controls disable and show a busy state, so a double-click cannot fire the same request twice — the most common way an action button creates duplicate records. |
| error | SvDataError | string | null | null | Failure from the last action, when it belongs to no single comment. Rendered above the thread; nothing is removed from view, so the user can see what state things are in. Prefer `commentErrors` for anything that failed on a comment that is on screen — see the note there. |
| commentErrors | Readonly<Record<string, SvDataError | string>> | {} | Failures keyed by comment id, rendered beside the comment they belong to. `error` above renders once, at the top. That is right for a failure with no place on the page and wrong for every failure this component actually produces: a reply, an edit, a delete and a reaction all name a comment, and a thread of forty puts the message a screenful away from the button that caused it. Worse, a second failure overwrites the first, so a user who retried two comments sees one message and cannot tell which one it is about. Key a reply failure by its `parentId` — the comment being replied to is what is on screen; the reply itself was never posted and has no id yet. Unlike `error`, this map DOES go down the tree: it is keyed, so a nested reply's failure lands on the reply and nowhere else. |
| deletedMessage | string | 'This comment was deleted.' | Copy for a comment the host has tombstoned (`deleted: true`). |
| loading | boolean | false | True while the thread itself is being fetched. Distinct from `busyIds`, which marks individual comments whose *action* is in flight. |
| loadError | SvDataError | string | null | null | The thread could not be loaded. The `…Load` spelling exists because `error` above is already taken, and it means something else: *the reply you just posted failed*. That is a different event with a different recovery — the control is still on screen and you retry by pressing it again, so it is deliberately not retryable. A failed *fetch* leaves nothing to press, so it gets its own error and its own retry. Same precedent as `sv-toolbar` and `sv-tool-call-card`; overloading one input with both meanings is what the rule exists to stop. |
| emptyMessage | string | '' | Copy for the zero-comment state. |
| filtered | boolean | false | Whether a filter or search is narrowing the thread, so the empty state can say *no matching comments* rather than *no comments* — telling someone with four hundred comments that there are none reads as a bug. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| retryLoad | void | The host should re-fetch the thread. Pairs with `loadError`, not `error`. |
| reply | { parentId: string; text: string } | Emitted when a reply is submitted. |
| react | CommentReactEvent | A reaction pill was toggled. The pill has already moved — persist it, or call `rollback()`. |
| delete | string | Emitted when a comment is deleted. |
| edit | { commentId: string; text: string } | Emitted when an owned comment is edited and saved. |
| helpful | { commentId: string } | Emitted when a review is marked helpful (review variant). |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
ProBound to the names this component actually exposes.
Host class
ProDrive the three async inputs from your own fetch.
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `comments`, `loading` and `error`.
- Set `filtered` when a search or filter is active. It is the difference between "you have no orders" and "no orders match this filter" — telling a customer with 4,000 orders the first one reads as a bug.
- The load-level retry is `retryLoad` here, because `retry` already means a per-item action on this component.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Cap nesting with `maxDepth` and toggle the inline `showReplyBox`.
- Identify the viewer via `currentUserId` to reveal inline Edit and Delete on their own comments.
- Handle `reply`, `react`, `edit` and `delete` outputs to persist changes.
- Reactions are optimistic: the pill and its count move on press and are reconciled against the next `comments` value you supply, so nothing has to be re-rendered to make a press feel immediate.
- `busyIds` locks one comment's controls mid-write and reaches nested replies automatically; `error` reports a failed action above the thread, `loading` / `loadError` / `retryLoad` cover the fetch.
- Comment bodies wrap long words and URLs (`break-words`) so they never overflow the bubble on narrow screens.
- A `review` variant renders comments as product reviews (star rating, verified-purchase badge and helpful votes); the Comment type gains `rating`, `verified` and `helpfulCount`.
- `deleted: true` renders a tombstone in place of a comment — withheld content and identity, no actions, timestamp kept, replies intact — and `deletedMessage` supplies the wording.
- `commentErrors` is a map of comment id to failure, rendered beside that comment and passed down to nested replies; `error` stays for a failure that belongs to no comment on screen.
Best practices
- Do: Limit nesting with `maxDepth` so deep threads stay readable on mobile.
- Do: Pass `currentUserId` so edit/delete only appear on the viewer's own comments.
- Do: Bind `busyIds` whenever you handle `react`. It locks the comment while its write is in flight, which is the only thing that keeps `rollback()` unambiguous — and it is passed down to nested replies for you.
- Do: Call `rollback()` from the error branch of the reaction write. The pill has already moved; nothing else puts it back.
- Do: Send `requestKey` with the write and have the server ignore a key it has already applied. An interceptor that retries a timeout otherwise turns one reaction into two.
- Do: Apply the event's `reacted` / `count` when the write succeeds rather than toggling what is in your array, so the same answer arriving twice cannot invert it back.
- Don't: Don't expect the pill to wait for your array. It moves on press, and the value you hand back next is what wins — so a `react` handler that does nothing leaves the pill showing an unsaved reaction forever.
- Don't: Don't use `error` for a failed fetch. It renders above a thread that is still there; `loadError` replaces the thread and carries its own retry.
- Do: Mark a parent `deleted: true` and leave it in the array when it has replies. Removing the node removes the replies, which is other people's writing.
- Do: Prefer `commentErrors` over `error` for anything that failed on a comment that is on screen — a reply, an edit, a delete, a reaction. It puts the message beside the button that caused it instead of a screenful away, and two failures no longer overwrite each other.
- Do: Report a delete failure through `commentErrors` (or `error`) rather than silently leaving the comment in place. It is also what returns focus to the Delete button that was pressed.
- Don't: Don't move focus yourself after `delete`. The component waits until your array no longer contains the comment, then lands on whatever took its place — moving focus at emit time parks it on a button that is about to disappear.
Accessibility
- Reaction toggles expose `aria-pressed` and a descriptive label (emoji + count), so state is not conveyed by color alone — and both track the optimistic value, so the announced state matches what is drawn.
- A comment in `busyIds` disables its pills, Delete and Send and marks them `aria-busy`, so an in-flight write is announced rather than only implied by an unresponsive button.
- Opening reply or edit moves focus into the textarea, which carries an explicit label; Edit exposes `aria-expanded`.
- Timestamps expose readable values and avatars fall back to initials with an accessible name.
- Focus is never dropped: posting a reply returns it to that comment's Reply button, saving or cancelling an edit returns it to Edit, and a failed delete returns it to Delete. Each of those controls destroys the element that had focus, and the browser's only fallback is the top of the page.
- A confirmed delete moves focus to the comment that took its place — the previous one when the last comment goes, the thread itself when none is left — and announces the removal in a polite live region, since a comment simply ceasing to exist is otherwise silent.
Feedback on Comment Thread
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.