Build a threaded comments panel
By the end of this recipe you'll have a comments panel attached to a record: top-level posts with one level of nested replies, @mention autocomplete, edit-in-place with an "edited" indicator, 👍 reactions, and a resolved-state that hides comments but never deletes them.
Overview
Comments attach to a record. Each one is a tiny document with its own state — author, body, reactions, edit history, resolved flag.
Comments turn a record into a conversation. For an SMB workflow — a leave request, a sales lead, a stock-count discrepancy — the thread is where the actual decision-making happens, separate from the audit log. This recipe keeps the data model flat (one comment per row, parentId for replies) and the rendering shallow (one level of nesting only). Two levels in, threading becomes unreadable; flat-but-tagged is what real teams use.
Never lose a comment. Edit history is preserved (small "edited" indicator). Resolving doesn't delete — it hides until "Show resolved" is toggled. Comments are evidence; treat them like it.
Skip this recipe if your workflow doesn't have decisions to make. A read-only audit log or an activity feed is a different pattern — quieter, append-only, no replies.
What you'll build
Three states of the thread: idle, composing with an @mention, and a resolved one tucked away.
Required pieces
Most pieces are blocks — the conversation pattern is small enough that one component holds the whole thread.
@corelithzw/react exports used here: CommentsThread, useComments, TextArea, Combobox, Avatar, Button, Stack, Toast, useToast.
Step-by-step build
Model the comment as a flat row with a parent pointer
One table, not a tree. Each comment carries id, optional parentId, and an edits array. Two-level rendering means anything with a non-null parentId attaches to its parent; deeper nesting is flattened to the same level so threads don't shrink off the right edge.
EDIT pushes the old body onto edits[], replaces body
No DELETE action. Resolved hides; nothing is destroyed.
Switch to the Code tab to see the snippet.
Render the thread with one level of nesting
Group comments by parentless then attach replies. Each comment is its own component with author, body, time, reactions and a row of actions. Replies indent by a single step; deeper nesting flattens to the same indent so the thread never disappears off the right.
Do we have a date for the Bulawayo audit?
Friday 14:00 works. Will bring float sheets.
Compose with @mention autocomplete
Watch the textarea for an @ followed by characters. Open a small Combobox anchored under the caret with matching workspace members. Selecting injects the rendered mention and closes the popover. Don't try to make the cursor "stick inside" the mention — let it be a normal text token that you serialise on submit.
Edit in place — and keep the history
Clicking "Edit" replaces the body with a small textarea pre-filled with the current body. Submitting dispatches EDIT, which pushes the old body onto edits[]. The small "edited" indicator hovers a tooltip with the edit count; the change is visible but quiet.
Final composition
The whole CommentsPanel — reducer, thread, composer, edit, resolve toggle.
3 unresolved
Show resolved (4)Do we have a date for the Bulawayo audit?
Variations
Three forks. Each is a small diff from the final composition.
Read-only mode
Past records that can be read but not posted to. Hide the composer, drop the action buttons.
<CommentsPanel readOnly />
// inside CommentRow
{!readOnly && (
<footer className="actions">…</footer>
)}
// CommentsPanel skips <Composer/>
{!readOnly && <Composer />}
Inline field annotations
Comments anchored to a specific field, not the record. Add anchor: { field, value }; render a margin pin next to that field.
type Comment = { /* … */
anchor?: { field: string };
};
// Render a pin on the field
<Field anchor={
comments.filter(c => c.anchor?.field === 'name')
} />
Summary view ("3 unresolved")
For a list of records, show only the unresolved count + last author. The whole thread expands on click.
const counts = comments
.filter(c => !c.resolved).length;
return (
<Button variant="ghost"
onClick={openThread}>
💬 {counts} unresolved
</Button>
);
Accessibility
A comments thread is a live region wrapped around a list of articles. Get the semantics right and screen readers do the work.
- The thread is a
role="feed". Each comment is an<article>with anaria-labelthat names the author and time. Screen readers can step "next article". - Times are real
<time>elements.dateTime="…ISO…"exposes the machine-readable timestamp; the visible label is a relative ("2h ago"). - The "edited" indicator carries a tooltip.
titlereads "Edited 2×" so the count is available without a click. - Reactions are
aria-pressedtoggles. A reaction button reads "Thumbs up, pressed, 2 reactions" — both state and count in one breath. - The composer textarea is labelled. Placeholder isn't a label; an associated
aria-labelsays "New comment". - "Show resolved" is announced. The toggle is a real button with
aria-expanded; flipping it doesn't move focus, but the new comments are inside the same feed region so SR users hear "feed updated". - @mention popover is a
Combobox. Arrow keys navigate candidates, Enter selects, Esc closes — the textarea stays in focus throughout.