@hasthiya_/headless-comments-react
A headless-first React comment engine: standalone hooks, composable per-comment primitives, pluggable adapters, and optional styled presets. TypeScript-native with generic Comment<T> support.
Installation
Install the package with your package manager. Only React 18+ and React-DOM are required as peer dependencies.
npm install @hasthiya_/headless-comments-react
# or
yarn add @hasthiya_/headless-comments-react
# or
pnpm add @hasthiya_/headless-comments-reactQuick Start
The fastest way to get a comment section running. Use useCommentTree for state management and pass the tree to any preset component. No manual state, no custom handlers needed.
import '@hasthiya_/headless-comments-react/presets/styled/styles.css';
import {
StyledCommentSection,
useCommentTree,
type CommentUser,
} from '@hasthiya_/headless-comments-react';
const currentUser: CommentUser = {
id: 'user-1',
name: 'John Doe',
avatarUrl: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=JohnDoe',
isVerified: true,
};
function App() {
const tree = useCommentTree({
initialComments: [],
currentUser,
});
return (
<StyledCommentSection
tree={tree}
currentUser={currentUser}
showReactions
includeDislike
/>
);
}A full live demo with Default, Shadcn, and Styled presets is on the homepage.
Architecture
The library is organized into three independent layers. Each can be used on its own or combined:
Core (framework-agnostic)
Pure functions for tree manipulation (addToTree, removeFromTree, updateInTree, toggleReactionInTree, exclusiveToggleReactionInTree), sorting, filtering, and the adapter interface. Zero React dependency — use these in Node.js, Vue, or any runtime.
import { addToTree, sortComments } from '@hasthiya_/headless-comments-react/core'
Headless React Layer
React hooks and unstyled components. useCommentTree for standalone state, composable hooks (useEditComment, useReplyTo, useCommentReaction) for per-comment logic, and CommentSectionProvider for context distribution.
import { useCommentTree, useEditComment } from '@hasthiya_/headless-comments-react' (or /headless)
Presets (UI)
Ready-to-use styled components. StyledCommentSection (CSS variables, no Tailwind) and CommentSection (minimal unstyled). Both accept a tree prop from useCommentTree.
useCommentTree
The flagship hook. Manages all comment state internally: add, reply, edit, delete, and reactions with correct count toggling. Works standalone (no Provider required) and supports adapters for persistence.
Basic usage (local state only)
import { useCommentTree, type Comment, type CommentUser } from '@hasthiya_/headless-comments-react';
const currentUser: CommentUser = { id: 'user-1', name: 'John Doe' };
function MyComments() {
const tree = useCommentTree({
initialComments: existingComments,
currentUser,
});
// tree.comments — the current nested comment array
// tree.addComment('Hello!') — add a root comment
// tree.addReply(parentId, 'Great point!') — reply to a comment
// tree.editComment(id, 'Updated text') — edit a comment
// tree.deleteComment(id) — delete a comment and its replies
// tree.toggleReaction(id, 'like') — toggle a reaction (count auto-updates)
// tree.totalCount — total comments including nested replies
// tree.findComment(id) — find a comment by ID in the tree
return <div>{/* render tree.comments */}</div>;
}Mutually exclusive reactions
Enable mutuallyExclusiveReactions to allow only one active reaction per comment. Clicking a new emoji deactivates the previous one, matching patterns like Facebook (one reaction at a time) or Reddit (up/down vote).
const tree = useCommentTree({
initialComments,
currentUser,
mutuallyExclusiveReactions: true, // Only one active reaction per comment
});
// Clicking 👍 when ❤️ is active:
// ❤️ count goes down, 👍 count goes up
// Clicking 👍 when 👍 is already active:
// 👍 count goes down (deactivated)With an adapter (REST API)
import {
useCommentTree,
createRestAdapter,
} from '@hasthiya_/headless-comments-react';
const adapter = createRestAdapter({
baseUrl: '/api/comments',
headers: { Authorization: `Bearer ${token}` },
});
function MyComments() {
const tree = useCommentTree({
currentUser,
adapter,
onError: (err) => console.error('Adapter error:', err),
});
// tree.isLoading — true while fetching initial comments
// tree.error — Error object if adapter failed (null otherwise)
// All mutations are optimistic with automatic rollback on failure
}Passing tree to preset components
When you pass the tree prop, the component uses your external tree instead of creating its own internal one. This gives you full control over the comment state.
// The component uses your tree — no duplicate state
<StyledCommentSection
tree={tree}
currentUser={currentUser}
showReactions
includeDislike
/>
// Works with any preset
<CommentSection tree={tree} currentUser={currentUser} />
<ShadcnCommentSection tree={tree} currentUser={currentUser} />UseCommentTreeOptions
| Option | Type | Description |
|---|---|---|
| initialComments | Comment[] | Initial comments (flat or nested, auto-detected) |
| currentUser | CommentUser | Current user for authoring new comments |
| adapter | CommentAdapter<T> | Adapter for async persistence (REST, Supabase, etc.) |
| generateId | () => string | Custom ID generator (default: generateUniqueId) |
| defaultReactions | ReactionConfig[] | Default reactions for new comments |
| onError | (error: Error) => void | Called when an adapter operation fails (after rollback) |
| mutuallyExclusiveReactions | boolean | When true, only one reaction can be active per comment at a time. Clicking a new reaction deactivates the previous one. |
UseCommentTreeReturn
| Property | Type | Description |
|---|---|---|
| comments | Comment<T>[] | Current nested comment tree |
| addComment | (content) => Comment<T> | Add a root-level comment (returns it immediately) |
| addReply | (parentId, content) => Comment<T> | Add a reply to a comment |
| editComment | (id, content) => Promise<void> | Edit a comment (optimistic + adapter) |
| deleteComment | (id) => Promise<void> | Delete a comment and its subtree |
| toggleReaction | (commentId, reactionId) => Promise<void> | Toggle a reaction (auto increment/decrement count) |
| setComments | (comments) => void | Replace the entire tree |
| findComment | (id) => Comment<T> | undefined | Find a comment by ID in the tree |
| totalCount | number | Total comments (including nested replies) |
| isLoading | boolean | True while loading initial data from adapter |
| error | Error | null | Current error (null if none) |
Composable Hooks
Granular, per-comment hooks for edit, reply, and reaction logic. Each hook is context-optional: it reads from CommentSectionProvider if available, or you can pass explicit callbacks for standalone use.
useEditComment(commentId, options?)
Manages edit state for a single comment: enter/exit edit mode, track content changes, handle submission.
import { useEditComment } from '@hasthiya_/headless-comments-react';
function EditButton({ commentId, currentContent }: { commentId: string; currentContent: string }) {
const {
isEditing,
editContent,
setEditContent,
startEditing,
submitEdit,
cancelEdit,
isSubmitting,
} = useEditComment(commentId, {
// Optional: provide your own handler (otherwise uses Provider context)
onEdit: async (id, content) => {
await fetch(`/api/comments/${id}`, { method: 'PATCH', body: JSON.stringify({ content }) });
},
});
if (isEditing) {
return (
<div>
<textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} />
<button onClick={submitEdit} disabled={isSubmitting}>Save</button>
<button onClick={cancelEdit}>Cancel</button>
</div>
);
}
return <button onClick={() => startEditing(currentContent)}>Edit</button>;
}useReplyTo(commentId, options?)
Manages reply form state: open/close form, track content, handle submission.
import { useReplyTo } from '@hasthiya_/headless-comments-react';
function ReplyButton({ commentId }: { commentId: string }) {
const {
isReplying,
replyContent,
setReplyContent,
openReply,
submitReply,
cancelReply,
isSubmitting,
} = useReplyTo(commentId);
if (isReplying) {
return (
<div>
<textarea value={replyContent} onChange={(e) => setReplyContent(e.target.value)} />
<button onClick={submitReply} disabled={isSubmitting}>Reply</button>
<button onClick={cancelReply}>Cancel</button>
</div>
);
}
return <button onClick={openReply}>Reply</button>;
}useCommentReaction(commentId, options?)
Toggle reactions with pending state tracking per reaction ID.
import { useCommentReaction } from '@hasthiya_/headless-comments-react';
function ReactionBar({ commentId }: { commentId: string }) {
const { toggle, isPending, reactions } = useCommentReaction(commentId);
return (
<div className="flex gap-1">
{reactions.map((r) => (
<button
key={r.id}
onClick={() => toggle(r.id)}
disabled={isPending(r.id)}
className={r.isActive ? 'bg-blue-100' : ''}
>
{r.emoji} {r.count > 0 && r.count}
</button>
))}
</div>
);
}useComment(comment, options?)
All-in-one hook that composes useEditComment, useReplyTo, and useCommentReaction for a single comment. When used via useComment, edit.startEditing() can be called with no arguments — it pre-fills with the comment's current content. Options: onEdit, onReply, onReaction, onDelete, currentUser (all optional; fall back to Provider). Returns isPendingDelete when the delete handler returns a Promise.
import { useComment } from '@hasthiya_/headless-comments-react';
function MyComment({ comment }) {
const {
isAuthor,
edit,
reply,
reaction,
showReplies,
toggleReplies,
deleteComment,
isPendingDelete,
} = useComment(comment);
return (
<div>
<p>{comment.content}</p>
{isAuthor && <button onClick={() => edit.startEditing()}>Edit</button>}
{isAuthor && (
<button onClick={() => confirm('Delete?') && deleteComment()} disabled={isPendingDelete}>
{isPendingDelete ? 'Deleting…' : 'Delete'}
</button>
)}
<button onClick={reply.openReply}>Reply</button>
<button onClick={toggleReplies}>
{showReplies ? 'Hide' : 'Show'} Replies
</button>
</div>
);
}useSortedComments(comments, initialOrder?, options?)
Sort comments with optional localStorage persistence of the user's sort preference.
import { useSortedComments } from '@hasthiya_/headless-comments-react';
function SortableList({ comments }) {
const { sortedComments, sortOrder, setSortOrder } = useSortedComments(
comments,
'newest',
{ persistKey: 'my-app-sort-order' } // optional: saves to localStorage
);
return (
<div>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="top">Top</option>
<option value="popular">Popular</option>
</select>
{sortedComments.map((c) => <Comment key={c.id} comment={c} />)}
</div>
);
}
// sortOrder: 'newest' | 'oldest' | 'top' | 'popular' (top and popular are equivalent)Adapters
Adapters connect useCommentTree to data sources. Implement only the methods you need: read-only adapters need only getComments; mutation methods (createComment, updateComment, deleteComment, toggleReaction) are optional and default to local-only updates when omitted. Optional subscribe for realtime and dispose for cleanup.
createInMemoryAdapter
In-memory adapter with simulated async delay. Great for prototyping and tests.
import { useCommentTree, createInMemoryAdapter } from '@hasthiya_/headless-comments-react';
const adapter = createInMemoryAdapter({
initialComments: seedComments,
latency: 200, // simulated network delay in ms
});
const tree = useCommentTree({ currentUser, adapter });createRestAdapter
REST adapter that maps CRUD operations to HTTP endpoints. Supports custom headers, error handling, and request cancellation.
import { useCommentTree, createRestAdapter } from '@hasthiya_/headless-comments-react';
const adapter = createRestAdapter({
baseUrl: '/api/comments',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
// API endpoints expected:
// GET /api/comments — fetch all comments
// POST /api/comments — create comment { content, parentId? }
// PATCH /api/comments/:id — update comment { content }
// DELETE /api/comments/:id — delete comment
// POST /api/comments/:id/react — toggle reaction { reactionId }
const tree = useCommentTree({ currentUser, adapter });Custom adapter
Implement the CommentAdapter<T> interface for any backend.
import type { CommentAdapter, Comment } from '@hasthiya_/headless-comments-react';
const myAdapter: CommentAdapter = {
async getComments() {
const res = await fetch('/api/comments');
return res.json(); // Comment[] or { comments, total, hasMore }
},
async createComment(content, parentId?) {
const res = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ content, parentId }),
});
return res.json(); // Comment with server-generated ID
},
async updateComment(id, content) {
const res = await fetch(`/api/comments/${id}`, {
method: 'PATCH',
body: JSON.stringify({ content }),
});
return res.json();
},
async deleteComment(id) {
await fetch(`/api/comments/${id}`, { method: 'DELETE' });
},
async toggleReaction(commentId, reactionId) {
await fetch(`/api/comments/${commentId}/react`, {
method: 'POST',
body: JSON.stringify({ reactionId }),
});
},
// Optional: realtime updates
subscribe(listener) {
const ws = new WebSocket('/ws/comments');
ws.onmessage = (e) => listener(JSON.parse(e.data));
return () => ws.close();
},
dispose() {
// cleanup connections
},
};CommentAdapter Interface
| Method | Signature | Required |
|---|---|---|
| getComments | (options?) => Promise<Comment[] | PaginatedResponse> | Optional |
| createComment | (content, parentId?) => Promise<Comment> | Optional |
| updateComment | (id, content) => Promise<Comment> | Optional |
| deleteComment | (id) => Promise<void> | Optional |
| toggleReaction | (commentId, reactionId) => Promise<void> | Optional |
| subscribe | (listener) => () => void | Optional |
| dispose | () => void | Optional |
Loading and error handling
When using an adapter, tree.isLoading is true while initial comments load. Use CommentSkeleton when tree.isLoading && tree.comments.length === 0. Wrap the section in CommentSectionErrorBoundary to catch errors and optionally render a fallback(error, reset).
import { useCommentTree, CommentSkeleton, CommentSectionErrorBoundary } from '@hasthiya_/headless-comments-react';
const tree = useCommentTree({ currentUser, adapter });
<CommentSectionErrorBoundary fallback={(err, reset) => (
<div><p>Error: {err.message}</p><button onClick={reset}>Try again</button></div>
)}>
{tree.isLoading && tree.comments.length === 0 && <CommentSkeleton count={3} />}
{!tree.isLoading && tree.comments.length > 0 && <YourCommentList tree={tree} />}
</CommentSectionErrorBoundary>Core Utilities
Pure, framework-agnostic functions for tree manipulation, sorting, and filtering. Import from the package root or @hasthiya_/headless-comments-react/core.
Tree mutation functions
All tree functions are immutable — they return a new array, never mutate the original.
import {
addToTree,
removeFromTree,
updateInTree,
toggleReactionInTree,
exclusiveToggleReactionInTree,
findCommentById,
flattenComments,
buildCommentTree,
countReplies,
} from '@hasthiya_/headless-comments-react';
// Add a comment to the tree
const updated = addToTree(tree, newComment, parentId, 'append'); // or 'prepend'
// Remove a comment (and its subtree)
const afterDelete = removeFromTree(tree, commentId);
// Update a comment's fields
const afterEdit = updateInTree(tree, commentId, { content: 'new content', isEdited: true });
// Toggle a reaction (auto increments/decrements count and flips isActive)
const afterReaction = toggleReactionInTree(tree, commentId, 'like');
// Toggle with mutual exclusivity (only one active reaction at a time)
// If 'heart' is active: deactivates it, then activates 'like'
const exclusive = exclusiveToggleReactionInTree(tree, commentId, 'like');
// Find a comment by ID (recursive)
const comment = findCommentById(tree, 'comment-123');
// Flatten nested tree to a flat array
const flat = flattenComments(tree);
// Build nested tree from flat array (using parentId)
const nested = buildCommentTree(flatComments);
// Count all replies recursively
const replyCount = countReplies(comment);Sorting and filtering
import {
sortComments,
filterComments,
searchComments,
} from '@hasthiya_/headless-comments-react';
// Sort by newest, oldest, popular, or top (top and popular are equivalent)
const sorted = sortComments(comments, 'newest');
const top = sortComments(comments, 'top'); // or 'popular' — net positive reactions first
// Sort recursively (sorts replies too)
const deepSorted = sortComments(comments, 'newest', { recursive: true });
// Filter comments
const filtered = filterComments(comments, (c) => !c.isDeleted);
// Search comments by content
const results = searchComments(comments, 'react hooks');All core exports
| Function | Signature | Description |
|---|---|---|
| addToTree | (tree, comment, parentId, position) => Comment[] | Add comment to tree (root or nested) |
| removeFromTree | (tree, commentId) => Comment[] | Remove comment and its subtree |
| updateInTree | (tree, commentId, partial) => Comment[] | Update comment fields |
| toggleReactionInTree | (tree, commentId, reactionId) => Comment[] | Toggle reaction with count update |
| exclusiveToggleReactionInTree | (tree, commentId, reactionId) => Comment[] | Toggle reaction with mutual exclusivity (one active at a time) |
| findCommentById | (tree, id) => Comment | undefined | Find comment recursively |
| flattenComments | (tree) => Comment[] | Flatten to array |
| buildCommentTree | (flat) => Comment[] | Build nested tree from flat list |
| countReplies | (comment) => number | Count replies recursively |
| sortComments | (comments, order, options?) => Comment[] | Sort by newest, oldest, popular or top |
| filterComments | (comments, predicate) => Comment[] | Filter comments |
| searchComments | (comments, query) => Comment[] | Search by content |
| generateUniqueId | () => string | Unique ID for comments |
| formatRelativeTime | (date, locale?) => string | e.g. "2 hours ago" |
Styling
Four ways to style the comment section. Each approach works with both the tree prop pattern and the legacy props pattern.
1. Styled preset (CSS variables)
Import the preset stylesheet and override --cs-* variables. See the Styled Components page for the full list.
import '@hasthiya_/headless-comments-react/presets/styled/styles.css';
import { StyledCommentSection, useCommentTree } from '@hasthiya_/headless-comments-react';
const tree = useCommentTree({ initialComments, currentUser });
<StyledCommentSection tree={tree} currentUser={currentUser} />Override variables in your CSS:
:root {
--cs-primary-color: #8b5cf6;
--cs-bg-color: #0f172a;
--cs-text-color: #f8fafc;
--cs-border-color: #334155;
}2. Theme prop
Pass a theme object for programmatic theming.
const theme = {
primaryColor: '#f97316',
backgroundColor: '#ffffff',
textColor: '#1f2937',
borderColor: '#e5e7eb',
borderRadius: '12px',
fontSize: '14px',
};
<StyledCommentSection tree={tree} currentUser={currentUser} theme={theme} />3. Render props (full control)
Use renderReplyForm, renderComment, renderAvatar, renderTimestamp to supply your own UI.
<CommentSection
tree={tree}
currentUser={currentUser}
renderReplyForm={({ onSubmit, placeholder }) => <MyForm onSubmit={onSubmit} placeholder={placeholder} />}
renderComment={(comment, props) => <MyCommentCard comment={comment} {...props} />}
renderAvatar={(user) => <img src={user.avatarUrl} alt={user.name} className="rounded-full" />}
/>4. Tailwind / Shadcn (copy-paste)
Copy the apps/showcase/src/components/comment-ui folder into your app. See the BYO UI page for examples.
import { ShadcnCommentSection } from '@/components/comment-ui';
import { useCommentTree } from '@hasthiya_/headless-comments-react';
const tree = useCommentTree({ initialComments, currentUser });
<ShadcnCommentSection tree={tree} currentUser={currentUser} showReactions />Component API
Reference for all component props. All types are exported from @hasthiya_/headless-comments-react.
CommentSectionProps (key props)
| Prop | Type | Default | Description |
|---|---|---|---|
| tree | UseCommentTreeReturn | — | Pre-configured tree from useCommentTree (recommended) |
| comments | Comment[] | — | Array of comments (alternative to tree prop) |
| currentUser | CommentUser | null | — | Logged-in user |
| onSubmitComment | (content) => Comment | — | Legacy: create comment manually (not needed with tree) |
| onReply | (commentId, content) => Comment | — | Legacy: add reply manually |
| onReaction | (commentId, reactionId) => void | — | Legacy: toggle reaction manually |
| onEdit | (commentId, content) => void | — | Legacy: edit comment manually |
| onDelete | (commentId) => void | — | Legacy: delete comment manually |
| showReactions | boolean | — | Show reaction buttons |
| showMoreOptions | boolean | — | Show more menu (reply, edit, delete) |
| includeDislike | boolean | false | Include dislike in default reactions |
| availableReactions | ReactionConfig[] | — | Custom reaction types |
| theme | CommentTheme | — | Colors, radius, font size, etc. |
| texts | CommentTexts | — | Labels and placeholders |
| maxDepth | number | 3 | Max reply nesting depth |
| sortOrder | 'newest' | 'oldest' | 'popular' | 'top' | newest | Sort order (top and popular are equivalent) |
| readOnly | boolean | false | Disable all interactions |
| inputPlaceholder | string | — | Placeholder for comment input |
| maxCharLimit | number | — | Max characters per comment |
| renderReplyForm | (props) => ReactNode | — | Custom form UI |
| renderComment | (comment, props) => ReactNode | — | Custom comment row |
| renderAvatar | (user) => ReactNode | — | Custom avatar |
| renderEmpty | () => ReactNode | — | Custom empty state |
StyledCommentSection (additional props)
| Prop | Type | Default | Description |
|---|---|---|---|
| showSortBar | boolean | true | Show sort bar (newest / oldest / top) |
Components
StyledCommentSection— Styled preset: CSS-only, no Tailwind. Import the stylesheet.CommentSection— Headless default: minimal unstyled UI.CommentSectionProvider— Context provider for distributing tree state to children.HeadlessCommentItem— Unstyled comment with render-prop children.HeadlessReplyForm— Unstyled reply form with render-prop children.CommentSkeleton— Unstyled loading skeleton; use whentree.isLoading && comments.length === 0.CommentSectionErrorBoundary— Error boundary for the comment subtree; optionalfallback(error, reset).
Types
All types support the generic Comment<T> pattern for custom metadata.
// Generic Comment with custom metadata
type MyComment = Comment<{ score: number; flair: string }>;
// The generic flows through all hooks and components
const tree = useCommentTree<{ score: number; flair: string }>({
initialComments: myComments,
currentUser,
});
// tree.comments is MyComment[]
// tree.addComment returns MyCommentCore types
| Type | Key fields | Description |
|---|---|---|
| Comment<T> | id, content, author, createdAt, updatedAt, parentId, replies, reactions, isEdited | Single comment node. T extends Record<string, unknown> for custom metadata. |
| CommentUser | id, name, avatarUrl?, isVerified?, role? | User (author) |
| Reaction | id, label, emoji, count, isActive | Reaction instance on a comment |
| ReactionConfig | id, label, emoji, activeColor?, inactiveColor? | Reaction type configuration |
| CommentAdapter<T> | getComments?, createComment?, updateComment?, deleteComment?, toggleReaction?, subscribe?, dispose? | Adapter interface; only implement methods you need (read-only: getComments only) |
| CommentTheme | primaryColor, backgroundColor, textColor, borderColor, borderRadius, fontSize | Theme configuration |
| CommentTexts | reply, edit, delete, cancel, submit, noComments, loading | Labels and placeholders |
| SortOrder | 'newest' | 'oldest' | 'popular' | 'top' | Sort order (popular and top are equivalent) |
Styled Example
The Styled preset is a polished, CSS-only comment section: one stylesheet import, no Tailwind or Radix. Theme via CSS variables and optional dark mode.
Bring Your Own UI
Use the headless hooks and components with your own design system. The BYO page demonstrates Reddit, Instagram, Facebook, and Slack-style UIs each with their own useCommentTree instance and mutuallyExclusiveReactions enabled. Each tab has independent state with a 5-emoji picker (Like, Love, Laugh, Wow, Angry) or Reddit-style up/down votes.