Recently I was inspired by the minimalist look and feel of the Hacker News comments section, and I wanted to try recreating it with React:
The goal of this post is to show you how to recreate the demo above, while allowing the comments section to be generated from any list of comments you give it, so long as they look somewhat like this:
const commentData = [{id: 1,parentId: null,text: 'Nice article! I love React!',author: 'sarah',children: null},{id: 2,parentId: 1,text: 'I agree Sarah, it\'s a great article. However, I prefer Vue.',author: 'john',children: null},{id: 4,parentId: 2,text: 'ah sh*t, here we go again..',author: 'sarah',children: null},{id: 3,parentId: null,text: 'Cool article. You should put some funny gifs in it or something.',author: 'kevin',children: null}];
In other words, our solution will be able to recursively generate a tree of comments from a flat array of comments data,
so long as they contain the right information to do so (such as parentId
if the comment is a reply to an existing comment).
Note: you can ignore the null children
property in each comment for now, but it will come in handy in a second.
But enough preamble - let's get started!
Step 1: Getting Started
For simplicity's sake, we'll keep all of our code together in one long-ish file, and use mostly inline styles:
const commentData = [{id: 1,parentId: null,text: 'Nice article! I love React!',author: 'sarah',children: null},{id: 2,parentId: 1,text: 'I agree Sarah, it\'s a great article. However, I prefer Vue.',author: 'john',children: null},{id: 4,parentId: 2,text: 'ah sh*t, here we go again..',author: 'sarah',children: null},{id: 3,parentId: null,text: 'Cool article. You should put some funny gifs in it or something.',author: 'kevin',children: null}];function App() {return (<div style={{ padding: '16px' }}>{commentData.map(comment => {return (<div>{comment.text}</div>)})}</div>)}ReactDOM.render(<App />, document.getElementById('root'));
Pretty simple - so far just a basic React app mapping through an array of comments:
Now let's make sure the comments are sorted and nested nicely as a tree of replies, and not just a long, visually confusing list. To do this, we'll need to create a function that converts our flat array of comments into a comment tree.
Step 2: Generating the Comment Tree
Luckily for us, a quick search of "convert flat array to tree javascript" into Google leads us to a popular Stack Overflow question, and a top answer that seems to have exactly what we need!
Let's go ahead and add it to our code. We'll name this function createTree()
, and also put it to use and console.log()
the results to make sure it's working:
function createTree(list) {var map = {},node,roots = [],i;for (i = 0; i < list.length; i += 1) {map[list[i].id] = i; // initialize the maplist[i].children = []; // initialize the children}for (i = 0; i < list.length; i += 1) {node = list[i];if (node.parentId) {// if you have dangling branches check that map[node.parentId] existslist[map[node.parentId]].children.push(node);} else {roots.push(node);}}return roots;}const commentData = [{id: 1,parentId: null,text: 'Nice article! I love React!',author: 'sarah',children: null},{id: 2,parentId: 1,text: 'I agree Sarah, it\'s a great article. However, I prefer Vue.',author: 'john',children: null},{id: 4,parentId: 2,text: 'ah sh*t, here we go again..',author: 'sarah',children: null},{id: 3,parentId: null,text: 'Cool article. You should put some funny gifs in it or something.',author: 'kevin',children: null}];function App() {const commentTree = createTree(commentData);console.log(commentTree);return (<div style={{ padding: '16px' }}>{commentData.map(comment => {return (<div>{comment.text}</div>)})}</div>)}ReactDOM.render(<App />, document.getElementById('root'));
Now peek in the console, and you should see an array containing some good news...
So our createTree()
did its job perfectly, but just because we have a properly nested array of comments
doesn't mean they're just going to magically appear that way in the UI for our users. Let's solve this in the next step.
Step 3: Creating the Comment Component
We're going to need a create a special <Comment />
component that knows how to handle this type of nested data, by not only
rendering itself but also recursively rendering any child comments (replies) beneath it. Thankfully I was able to find
a great article on rendering comments with nested children using React components, and they provide us with a great component to use.
I'll go ahead and add into our code so you can see how it works:
function createTree(list) {var map = {},node,roots = [],i;for (i = 0; i < list.length; i += 1) {map[list[i].id] = i; // initialize the maplist[i].children = []; // initialize the children}for (i = 0; i < list.length; i += 1) {node = list[i];if (node.parentId) {// if you have dangling branches check that map[node.parentId] existslist[map[node.parentId]].children.push(node);} else {roots.push(node);}}return roots;}const commentData = [...]; // let's abbreviate this from now onfunction Comment({ comment }) {const nestedComments = (comment.children || []).map(comment => {return <Comment key={comment.id} comment={comment} />})return (<div style={{marginLeft: "25px", marginTop: "10px"}}><div style={{fontWeight: 700}}>{comment.author}</div><div>{comment.text}</div>{nestedComments}</div>)}function App() {const commentTree = createTree(commentData);return (<div style={{ padding: '16px' }}>{commentTree.map(comment => { // notice we are now using `commentTree` and not `commentData`return <Comment comment={comment} />})}</div>)}ReactDOM.render(<App />, document.getElementById('root'));
The <Comment />
component above is going to recursively add any comment replies below itself, while
also being a great place to add some more detail and styling.
And just like that, our comments have really started to take shape:
Now all that's left is some styling and little bit of added functionality, which I'll show you how to get started on in the final step.
Step 4: Styling and Interactivity
To get our imitation as close as possible we can inspect the comments of any Hacker News post and discover that the font in
use is Verdana and the background color is #F6F6EF
. We can also find the different font sizes and colors of the different
pieces of text and apply those as well.
But what about hiding/collapsing different parts of the comment tree? Or enabling new replies?
Let me show you how to do the former, and then finding out how to do the latter won't be too hard:
import { useState, useEffect } from 'react';function createTree(list) {...} // don't forget to keep this function!const commentData = [{id: 1,parentId: null,text: 'Nice article! I love React!',author: 'sarah',children: null,expanded: true // new comment property!},{id: 2,parentId: 1,text: 'I agree Sarah, it\'s a great article. However, I prefer Vue.',author: 'john',children: null,expanded: true},{id: 4,parentId: 2,text: 'ah sh*t, here we go again..',author: 'sarah',children: null,expanded: true},{id: 3,parentId: null,text: 'Cool article. You should put some funny gifs in it or something.',author: 'kevin',children: null,expanded: true}];function Comment({ comment, collapse }) {const nestedComments = (comment.children || []).map(comment => {return <Comment key={comment.id} comment={comment} />})return (<div style={{marginLeft: "25px", marginTop: "10px"}}><div style={{ display: 'flex', alignItems: 'center' }}><div style={{fontWeight: 700}}>{comment.author}</div><divonClick={() => collapse(comment.id)}style={{cursor: 'pointer',marginLeft: '6px',fontSize: '12px'}}>{comment.expanded ? `[-]` : `[+]`}</div></div>{comment.expanded && (<div><div>{comment.text}</div>{nestedComments}</div>)}</div>}function App() {const [comments, setComments] = useState(commentData);const [commentTree, setCommentTree] = useState(createTree(comments));useEffect(() => {setCommentTree(createTree(comments));}, [comments]);const handleCommentCollapse = (id) => {const updatedComments = comments.map((c) => {if (c.id === id) {return {...c,expanded: !c.expanded,};} else return c;});setComments(updatedComments);};return (<div style={{ padding: '16px' }}>{commentTree.map(comment => { // notice we are now using `commentTree` and not `commentData`return <Comment key={comment.id} comment={comment} collapse={handleCommentCollapse} />})}</div>)}ReactDOM.render(<App />, document.getElementById('root'));
With the above code, users can now now hide or expand any comment and its children by clicking the little plus/minus button:
The way we've made this work is by using some state, a handler (handleCommentCollapse()
), and some conditional rendering.
We had to move the commentData
into the comments
state so that they can be updated/edited. Changes to them are
tracked using a single useEffect
.
Using a similar strategy to the one above can allow you to make any changes to the comments and how they're displayed.
I'll leave you to decide how to do the rest of them, but if you'd like a hint on how to add reply functionality, it's by conditionally rendering a <textarea>
when the user clicks "reply", capturing its text value (and parentId
) when the user submits it, and sending it back up
the chain so it can be added into the comments
array via a handler function, immediately updating the comment tree and its display.
Just keep in mind that on a real forum website (or comments section) these comments would need to be stored in (and retrieved from) a database so that they actually persist. Otherwise they'll be erased each time the state refreshes.