@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-react

Quick 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

OptionTypeDescription
initialCommentsComment[]Initial comments (flat or nested, auto-detected)
currentUserCommentUserCurrent user for authoring new comments
adapterCommentAdapter<T>Adapter for async persistence (REST, Supabase, etc.)
generateId() => stringCustom ID generator (default: generateUniqueId)
defaultReactionsReactionConfig[]Default reactions for new comments
onError(error: Error) => voidCalled when an adapter operation fails (after rollback)
mutuallyExclusiveReactionsbooleanWhen true, only one reaction can be active per comment at a time. Clicking a new reaction deactivates the previous one.

UseCommentTreeReturn

PropertyTypeDescription
commentsComment<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) => voidReplace the entire tree
findComment(id) => Comment<T> | undefinedFind a comment by ID in the tree
totalCountnumberTotal comments (including nested replies)
isLoadingbooleanTrue while loading initial data from adapter
errorError | nullCurrent 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

MethodSignatureRequired
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) => () => voidOptional
dispose() => voidOptional

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

FunctionSignatureDescription
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 | undefinedFind comment recursively
flattenComments(tree) => Comment[]Flatten to array
buildCommentTree(flat) => Comment[]Build nested tree from flat list
countReplies(comment) => numberCount 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() => stringUnique ID for comments
formatRelativeTime(date, locale?) => stringe.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)

PropTypeDefaultDescription
treeUseCommentTreeReturnPre-configured tree from useCommentTree (recommended)
commentsComment[]Array of comments (alternative to tree prop)
currentUserCommentUser | nullLogged-in user
onSubmitComment(content) => CommentLegacy: create comment manually (not needed with tree)
onReply(commentId, content) => CommentLegacy: add reply manually
onReaction(commentId, reactionId) => voidLegacy: toggle reaction manually
onEdit(commentId, content) => voidLegacy: edit comment manually
onDelete(commentId) => voidLegacy: delete comment manually
showReactionsbooleanShow reaction buttons
showMoreOptionsbooleanShow more menu (reply, edit, delete)
includeDislikebooleanfalseInclude dislike in default reactions
availableReactionsReactionConfig[]Custom reaction types
themeCommentThemeColors, radius, font size, etc.
textsCommentTextsLabels and placeholders
maxDepthnumber3Max reply nesting depth
sortOrder'newest' | 'oldest' | 'popular' | 'top'newestSort order (top and popular are equivalent)
readOnlybooleanfalseDisable all interactions
inputPlaceholderstringPlaceholder for comment input
maxCharLimitnumberMax characters per comment
renderReplyForm(props) => ReactNodeCustom form UI
renderComment(comment, props) => ReactNodeCustom comment row
renderAvatar(user) => ReactNodeCustom avatar
renderEmpty() => ReactNodeCustom empty state

StyledCommentSection (additional props)

PropTypeDefaultDescription
showSortBarbooleantrueShow 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 when tree.isLoading && comments.length === 0.
  • CommentSectionErrorBoundary — Error boundary for the comment subtree; optional fallback(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 MyComment

Core types

TypeKey fieldsDescription
Comment<T>id, content, author, createdAt, updatedAt, parentId, replies, reactions, isEditedSingle comment node. T extends Record<string, unknown> for custom metadata.
CommentUserid, name, avatarUrl?, isVerified?, role?User (author)
Reactionid, label, emoji, count, isActiveReaction instance on a comment
ReactionConfigid, 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)
CommentThemeprimaryColor, backgroundColor, textColor, borderColor, borderRadius, fontSizeTheme configuration
CommentTextsreply, edit, delete, cancel, submit, noComments, loadingLabels 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.

See Styled Components

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.

See BYO UI Examples