DEV Community

Cover image for Build a Custom Comment Section
Curry Spook
Curry Spook

Posted on

Build a Custom Comment Section

Adding a comment section to a React app sounds simple. But then you try it, and every off-the-shelf solution either forces its own UI on you, dumps a pile of CSS you never asked for, or locks you into a specific backend. You end up spending more time fighting the library than building your product.

So what if you could get all the hard logic handled for you, and still keep complete control over how everything looks and feels? That is the whole point of @hasthiya_/headless-comments-react.

What Is It?

@hasthiya_/headless-comments-react is a headless comment engine for React. It ships a set of hooks that handle all the state and logic for a comment thread, including adding, replying, editing, deleting, reacting, and sorting, without rendering a single <div>. You bring the UI. The library brings the brains.

That separation is what makes this powerful. You can make your comment section look like Reddit, Discord, GitHub, or anything else you can design, all using the exact same underlying hooks.

Reddit UI

Github UI

Installation

npm install @hasthiya_/headless-comments-react
Enter fullscreen mode Exit fullscreen mode

Core Concepts

The library is built around two main hooks, both imported from @hasthiya_/headless-comments-react/headless.

useCommentTree manages the entire comment thread. It holds the list of comments and gives you methods for adding, editing, deleting, and reacting.

useComment manages a single comment. It gives you everything you need for that one comment: reply state, edit state, reaction toggles, and a boolean to check if the current user is the author.

On top of those, useSortedComments handles sorting by newest, oldest, or most popular, and the formatRelativeTime utility turns timestamps into readable strings like "5 minutes ago".

Quick Setup

Here is everything you need to get a fully working comment thread off the ground.

1. Define Your Current User

import type { CommentUser } from '@hasthiya_/headless-comments-react';

const currentUser: CommentUser = {
  id: 'user-1',
  name: 'Jane Doe',
  avatarUrl: 'https://example.com/avatar.jpg',
};
Enter fullscreen mode Exit fullscreen mode

2. Set Up the Comment Tree

import { useCommentTree } from '@hasthiya_/headless-comments-react/headless';

function CommentSection() {
  const tree = useCommentTree({
    initialComments: [], // load your existing comments here
  });

  return <CommentList tree={tree} currentUser={currentUser} />;
}
Enter fullscreen mode Exit fullscreen mode

3. Render Your Comments

import { useComment, useSortedComments } from '@hasthiya_/headless-comments-react/headless';
import { formatRelativeTime } from '@hasthiya_/headless-comments-react';
import type { Comment, UseCommentTreeReturn, CommentUser } from '@hasthiya_/headless-comments-react';

function CommentItem({
  comment,
  tree,
  currentUser,
}: {
  comment: Comment;
  tree: UseCommentTreeReturn;
  currentUser: CommentUser;
}) {
  const {
    isAuthor,
    edit,
    reply,
    reaction,
    showReplies,
    toggleReplies,
    deleteComment,
  } = useComment(comment, {
    onEdit: async (id, content) => tree.editComment(id, content),
    onReply: async (id, content) => tree.addReply(id, content),
    onReaction: async (id, reactionId) => tree.toggleReaction(id, reactionId),
    onDelete: async (id) => tree.deleteComment(id),
  });

  return (
    <div>
      <p><strong>{comment.author.name}</strong> · {formatRelativeTime(comment.createdAt)}</p>

      {edit.isEditing ? (
        <div>
          <textarea value={edit.editContent} onChange={e => edit.setEditContent(e.target.value)} />
          <button onClick={edit.submitEdit}>Save</button>
          <button onClick={edit.cancelEdit}>Cancel</button>
        </div>
      ) : (
        <p>{comment.content}</p>
      )}

      <div>
        <button onClick={() => reaction.toggle('like')}>👍</button>
        <button onClick={reply.isReplying ? reply.cancelReply : reply.openReply}>Reply</button>
        {isAuthor && (
          <>
            <button onClick={() => edit.startEditing(comment.content)}>Edit</button>
            <button onClick={deleteComment}>Delete</button>
          </>
        )}
      </div>

      {reply.isReplying && (
        <div>
          <textarea
            value={reply.replyContent}
            onChange={e => reply.setReplyContent(e.target.value)}
            placeholder="Write a reply..."
          />
          <button onClick={reply.submitReply}>Submit</button>
          <button onClick={reply.cancelReply}>Cancel</button>
        </div>
      )}

      {comment.replies && comment.replies.length > 0 && (
        <>
          <button onClick={toggleReplies}>
            {showReplies ? 'Hide' : 'Show'} {comment.replies.length} replies
          </button>
          {showReplies && comment.replies.map(r => (
            <CommentItem key={r.id} comment={r} tree={tree} currentUser={currentUser} />
          ))}
        </>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Add a Top-Level Composer and Sorting

function CommentList({
  tree,
  currentUser,
}: {
  tree: UseCommentTreeReturn;
  currentUser: CommentUser;
}) {
  const { sortedComments, sortOrder, setSortOrder } = useSortedComments(tree.comments, 'newest');
  const [text, setText] = useState('');

  return (
    <div>
      {/* Sort controls */}
      {(['newest', 'oldest', 'popular'] as const).map(order => (
        <button key={order} onClick={() => setSortOrder(order)}>
          {order}
        </button>
      ))}

      {/* New comment input */}
      <textarea value={text} onChange={e => setText(e.target.value)} placeholder="Leave a comment..." />
      <button
        onClick={() => { if (text.trim()) { tree.addComment(text.trim()); setText(''); } }}
        disabled={!text.trim()}
      >
        Comment
      </button>

      {/* Comment list */}
      {sortedComments.map(comment => (
        <CommentItem key={comment.id} comment={comment} tree={tree} currentUser={currentUser} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And that is genuinely all you need. You now have a fully functional, sortable, nestable comment thread with replies, edits, deletes, and reactions, with zero imposed styles and zero opinionated markup.

The Real Power: Bring Your Own UI

Because the hooks are completely decoupled from rendering, you can plug the exact same logic into any UI you want. The showcase at headless.hasthiya.dev proves this with 15+ platform-inspired styles including Reddit, GitHub, Discord, Twitter, and YouTube, all powered by identical hook calls underneath.

This means you are not locked into any design system. Whether you reach for Tailwind, CSS Modules, styled-components, or plain CSS, the library stays completely out of your way.

Connecting to a Real Backend

Each callback you pass to useComment is async, which makes it the perfect place to fire off API calls before updating local state. Here is what that looks like in practice:

const { ... } = useComment(comment, {
  onEdit: async (id, content) => {
    await fetch(`/api/comments/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ content }),
    });
    tree.editComment(id, content);
  },
  // ... other handlers
});
Enter fullscreen mode Exit fullscreen mode

Your data layer, your rules. The library does not care where your data lives or how you fetch it.

Summary

Feature Details
Zero UI No styles or markup imposed
Full comment CRUD Add, edit, delete, reply
Reactions Toggle any custom reaction
Sorting Newest, oldest, most popular
TypeScript Fully typed out of the box
Backend agnostic Wire up any API in async callbacks

If you have ever wanted a comment system that fits into your app instead of the other way around, this is the library for you.

npm install @hasthiya_/headless-comments-react
Enter fullscreen mode Exit fullscreen mode

Check out the live showcase and the full documentation to see everything it can do.

Top comments (0)