Recursively rendering comments for a developer content creation site ♻️

kurealnum

Oscar

Posted on November 6, 2024

Recursively rendering comments for a developer content creation site ♻️

I’ve been working on a new blogging developer content creation site (which I’ll be sharing more about soon), and I recently ran into a small problem - how on earth do you render a comments section?

render(comments) // what should happen in this function?
Enter fullscreen mode Exit fullscreen mode

Not just a list of comments, of course, but a fully functioning comments section specifically with the option to indent replies. It’s a little bit more complicated than you might think, and today, I’d like to share my solution with you.

A small outline ✍️

Before I do anything, it would be wise to give you a brief overview of the way I expect this comments section to work.

Each comment should:

  • Have general info about each comment (ex. username, date created, date edited)
  • Have a reply button, with the ability to reply (some sort of textbox to enter text in)
  • Have an edit/delete button on each comment, if the comment belongs to the current user

And the comments section as a whole should:

  • specifically have the option to indent comments that are replies.

Firstly, we need to add some basic features to each Comment component – namely the previously mentioned “general info”, reply button, and the edit/delete button. This isn’t that interesting though. It’s just a bunch of variables and ternary operators. Let’s get to the fun part – recursively rendering the comments.

The part you came for 🎊

Now I know what you’re thinking. Can’t you just mark each comment with as a reply/not a reply and then indent it if it is? Well, yes. But that’s not very extensible. If I ever want to do anything else with this comment section (like indenting replies to replies), I need to do something a tinyyy bit fancier. Additionally, I still need to render replies in order on the client’s end, so a basic boolean value on each comment wouldn’t really be appropriate here.

One option would be to calculate the “depth” of the comment on the server-side (in this case, using the Django ORM/Postgres) and then render it, like so:

Diagram of comments

… and while this isn’t a bad idea (recursive queries do exist!), why force our server to do work that the client could do by themselves?

And so I went looking for a client-side solution. I tried to apply a certain CSS class if the comment was a reply, and then another CSS class if said comment was a reply to a reply… but that just ended up being a whole heap of conditionals that didn’t really lead me anywhere.

A new idea 💡

If you recall a few paragraphs ago, I mentioned the idea of a recursive query on the server-side. I didn’t want to use it because again, why force our server to do work that our client could? But the idea of utilizing recursion was certainly plausible. After a bit of thinking, I remembered a technique that I learned from my time spent practicing LeetCode: backtracking. It should be noted that in the code you’ll see in just a moment, one may argue that this “backtracking” algorithm is more of a tree traversal. Either way, it was an idea.

It should be noted that backtracking is horribly inefficient for an input n larger than a few thousand… but when am I ever going to render 5000 comments all at once? Likely never. In fact, one of the biggest posts on Dev.to has only 189 comments (at the time of writing this, October 29th, 2024).

This is still viable, right? Well… yes! I’m sure there’s a better solution, but for my use case, this is totally fine. Let’s take a look at an example before we put this into code. Here’s our input:

// this is a very simplified version of the input, where the id is the id of the comment, content is the content of the comment, and reply refers to the comment id that the comment should be replying to. 

const data = [
 { id: 1, replyto: null, content: "content" }, 
{ id: 2, replyto: null, content: "content" }, 
{ id: 3, replyto: 1, content: "content" }, 
{ id: 4, replyto: 1, content: "content" }, 
{ id: 5, replyto: 2, content: "content" }, 
{ id: 6, replyto: 3, content: "content" }, 
]; 
Enter fullscreen mode Exit fullscreen mode

Now, we need to go through this input, measure the “depth” of each comment, and indent that comment according to its depth.

Let’s start with measuring the depth of each comment (AKA creating a few trees of comments using objects). We can do this like so (diagram first, then code!):

Diagram of below code

And for the code

const input = {};
const haveSeen = new Set([]);
data.forEach((comment) => {
    if (comment.replyto != null) {
            input[comment.replyto] = {
                ...input[comment.replyto],
                [comment.id]: comment.content,
        };
        haveSeen.add(comment.id);
        haveSeen.add(comment.replyto);
    }
});

data.forEach((comment) => {
    if (haveSeen.has(comment.id) == false) {
            input[comment.id] = {};
    }
});
Enter fullscreen mode Exit fullscreen mode

I won’t walk through each step of this part of the algorithm, but it’s pretty simple. The complicated part is all of the different variable names.

The rendering process 🖥️

Now that we have our “data structure”, all we need to do is traverse it and indent each comment according to its depth (AKA “rendering” it). I put the word “render” in quotes, because in this example, we aren’t actually rendering anything. We can do this recursively, as I previously mentioned, and use memoization to speed up our algorithm a little bit:

const memo = new Set([]);

function render(nodes, level) {
    return nodes.map((key) => {
            if (input[key] != undefined && memo.has(key) == false) {
                    const res = [key, render(Object.keys(input[key]), level + 1)];
            memo.add(key);
            return res
            }
            if (memo.has(key) == true) {
                    return null
            }
            return key;
    });
}

render(Object.keys(input), 1)
Enter fullscreen mode Exit fullscreen mode

There’s a lot going on here, so let’s break it down in pseudocode:

  • Declare a Set named “memo”.
  • Call the recursive render function.
  • If the comment at the current level has at least 1 or more replies and the comment’s ID is not in the memo, return the result of recursively calling the render function again.
  • If the comment doesn’t have any replies but the ID is in the memo, return null.
  • Finally, if the comment doesn’t have any replies but the ID is not in the memo, return the key.

You may have noticed that we’re returning nodes.map(...) inside the render function, and not just “key”. This is a tiny bit weird, but understand that this is necessary in practice. If it seems weird or confusing, just ignore it for now. Also, in practice, nodes.map(...)’s function does not return a key. It instead returns a JSX element.

Closing up 🏁

Finally, here’s how the render function is actually called:

render(Object.keys(input), 1)
Enter fullscreen mode Exit fullscreen mode

And … that’s it! Before you go, you might be interested in taking a look at how we actually use this in practice. It should clear up any confusion about why we return nodes.map(...) instead of just the key. I’ll attach the full code below.

Also, if you enjoyed this article, please consider reading some other things that I’ve written!

Extra info

Here’s how the comment section actually looks in practice:

Final product

And here’s the code:

const memo = new Set([]);

function renderedCommentsHelper(nodes, level) {
  const res = nodes.map((key) => {
    if (input[key] != undefined && memo.has(key) == false) {
    const recRes = (
        <>
            <Comment/>
            {renderedCommentsHelper(Object.keys(input[key]), level + 1)}
        </>
    );
        memo.add(key);
        return recRes;
    }
    if (memo.has(key) == true) {
        return null;
    }
    return (
            <Comment/>
    );
  });
  return res;
}

// creating an object to access any and all comment data in O(1)
const data = {};
raw.forEach((comment) => {
  data[comment.id] = comment;
});

// creating a tree to run renderedCommentsHelper on
const input = {};
const haveSeen = new Set([]);
raw.forEach((comment) => {
  if (comment.reply_to != null) {
    input[comment.reply_to.id] = {
    ...input[comment.reply_to.id],
    [comment.id]: null,
    };
    haveSeen.add(comment.id);
    haveSeen.add(comment.reply_to.id);
  }
});

raw.forEach((comment) => {
  if (haveSeen.has(comment.id) == false) {
    input[comment.id] = {};
  }
});

return (
  <ul className="list" key={1}>
    {renderedCommentsHelper(Object.keys(input), 1)}
  </ul>
);
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
kurealnum
Oscar

Posted on November 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related