Comment threads with recursive components in Vue 3

Comment threads with recursive components in Vue 3

It’s common for applications to use threaded comment replies for managing communication. In Vue, we can use recursion to help manage displaying those threads.

What is recursion?

Simply put, a recursive function is one that calls itself with some condition to bail us out to prevent an infinite loop. Here’s an example from Mozilla.

const factorial = (n) => {
  if (n === 0) {
    return 1;
  } else {
    return n * factorial(n - 1);
  }
};

console.log(factorial(10));
// 3628800

Vue makes it easy to do this, so let’s put together a basic example application to show how it works.

Prerequisites:

  • Familiarity with JS
  • Familiarity with Vue

It would also help to have some exposure to CSS, as we’ll be adding some basic styles to our app.

Step one: Create a new Vue app.

You can use Vue CLIVite, or whatever you’re comfortable with. For simplicity, I’ll just be using the CLI powered by StackBlitz to save us a bunch of local configuration.

From the homepage of StackBlitz.com, click “Vue 3” to create a new Vue app with the Vue CLI. That was easy. There’s a Hello World example to get you started, and we’ll modify these files a bit.

You can go ahead and delete components/HelloWorld.vue and replace the contents of App.vue with this bare-bones example. We’ll come back and improve this soon.

<template>
  <div id="app">
    <h1>Comments</h1>
  </div>
</template>

<script>
export default {
  name: 'App',
};
</script>

You should now see a simple, unstyled “Comments” heading in the preview pane of StackBlitz, like so:

Output of initial vue app setup

Step Two: Get some comments.

You would commonly get this from a service, but we’ll provide this as hard-coded data for our tutorial.

Let’s create a new folder at src/data for our comments and export some data into a new file at src/data/comments.js. You can paste this in from StackBlitz.

This data includes the comment author, content, dates, and replies. The replies are comments in the same format, with identical fields. Knowing these fields will help us create the Comment component in the next step. No need to get in the weeds on the data for now; we’ll look in a little more detail as we go along.

To get the data into our application, let’s go to src/App.vue and import our data at the top of the script section.

import commentsData from './data/comments.js';

Then we’ll get that data available to Vue by adding comments to the computed properties and returning the comments data. We might typically use the VueX store or grab our data directly from an API, but we’ll keep things simple today.

Our script section in src/App.vue should now look like this:

<script>
import commentsData from './data/comments.js';

export default {
  name: 'App',
  computed: {
    comments() {
      return [...commentsData];
    },
  },
};
</script>

Now, if you look at App.vue in the Vue Devtools, you should see your comments array. (If you don’t already have the Vue Devtools extension installed, you can download it here). Feel free to look at the array in more detail while we’re here.

Vue Devtools with comment data

Step Three: Create a Comment component.

Next, we’ll need a component for each individual comment. Let’s create a new file for the component at /src/components/Comment.vue and fill it with this boilerplate for now.

<template>
  <div class="comment">Comment</div>
</template>

<script>
export default {
  name: 'Comment',
};
</script>

Now let’s add that to App.vue and render the basic comment to the application. We’ll import and register the Comment component in the script block, then add the <Comment /> to the template. Our App.vue should now look something like this:

<template>
  <div id="app">
    <h1>Comments</h1>
    <div class="comments">
      <Comment />
    </div>
  </div>
</template>

<script>
import commentsData from './data/comments.js';

import Comment from './components/Comment.vue';

export default {
  name: 'App',
  components: {
    Comment,
  },
  computed: {
    comments() {
      return [...commentsData];
    },
  },
};
</script>

With our unstyled output, we should now see the comment header with a single comment below, like so:

Unstyled static comment output

Step Four: Add data to comments.

Let’s review our data again to see what we need to pass into each comment. In Vue Devtools, we can see we get an idauthorbodytimestamp, and an array of replies. The UI will need the authorbody, and timestamp. We’ll also need the replies to display threads in a later step. Let’s put some props in our Comment component to receive that data.

After adding some basic prop validation, our script block should now look like this:

<script>
export default {
  name: 'Comment',
  props: {
    author: { type: String, required: true },
    body: { type: String, required: true },
    timestamp: { type: String, required: true },
    replies: { type: Array, required: true },
  },
};
</script>

Next, we’ll add some markup in our template to render that data. Note that we use the v-html directive for our body since it contains HTML.

<template>
  <div class="comment">
    <header>
      <h3>{{ author }}</h3>
    </header>
    <div v-html="body" />
    <p>{{ timestamp }}</p>
  </div>
</template>

If we hardcode some props into App.vue, it should render our comment data as expected:

<Comment
  author="Marcus"
  body="<h1>Hello!</h1>"
  timestamp="10/21/2022 12:14:19"
  :replies="[]"
/>

Unstyled single comment output

But we don’t want to hard-code each comment, so let’s use the v-for and v-bind directives to bring the data to life instead.

<Comment v-for="comment in comments" :key="comment.id" v-bind="comment" />

One great thing about v-bind here is that since we named our props in Comment the same as the keys in our data, we don’t need to list every prop out here. We can bind all the props to the comment in one quick swoop while taking advantage of prop validation in our component.

With luck, our comments should be listed out like below.

Unstyled comment thread output

As you can see, this is just the first level of comments and doesn’t include the reply threads! We’ll add those soon, but let’s optionally get some styles in here to make this a little easier on the eyes.

Step 4.5: Add some styles

This step is optional but will help make our comments look a little better, and we’ll also be able to visualize the threads easier.

In App.vue, we’ll add a few global styles. Note that we don’t use the scoped attribute on the style block because we want all components to inherit these changes.

<style lang="css">
html {
  box-sizing: border-box;
}

*,
*:before,
*:after {
  box-sizing: inherit;
}

body {
  font-family: sans-serif;
}
</style>

Then in Comments.vue, we’ll make a couple of quick changes.

First, I added a comment icon inside the header.

<header>
  <h3>{{ author }}</h3>
  <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
</header>

I also added a computed property to make our date more readable. No need to get into the weeds on this one; it’s just something I grabbed from the web:

computed: {
  formattedDate() {
    const fmt = new Intl.DateTimeFormat('en-US', {
      month: 'long',
      day: 'numeric',
    });
    return fmt.format(Date.parse(this.timestamp));
  },
},

And then replaced the simple timestamp with our formattedDate in the template and added a class name to target styles. I also added a class name to the body prop.

<div v-html="body" class="comment-body" />
<p class="timestamp">{{ formattedDate }}</p>

And lastly, here’s some basic CSS to clean things up a little.

<style lang="css" scoped>
.comment {
  border: 1px solid DodgerBlue;
  border-radius: 0.5rem;
  margin-bottom: 1rem;
  padding: 1.5rem;
}

h3,
p {
  margin: 0;
}

header {
  align-items: center;
  display: flex;
  justify-content: space-between;
  margin-bottom: 0.75rem;
}

svg {
  fill: SlateGray;
}

.comment-body {
  margin-bottom: 0.375rem;
}

.timestamp {
  color: DimGray;
  font-size: 0.8rem;
}
</style>

With those changes, that should be looking better.

Styled comment thread output

Now that we’ve got the setup out of the way, we can start getting into the threaded replies.

Step 5: Use recursion to display replies.

If we look at our data again, we can see that each of our top-level comments contains a replies array. If there are no replies, the array is empty; otherwise, each object in the array contains another comment with the same shape as the top-level comments.

Back in our Comment component, let’s add a check to see if there are replies and if so, render out something to prove it.

I’ll start by wrapping our entire template in a single div so that comments and replies can be treated separately. And then, after the comment, I’ll render out the word “Replies” if there are indeed replies.

Your Comment template should look something like this now.

<template>
  <div>
    <div class="comment">
      <header>
        <h3>{{ author }}</h3>
        <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
      </header>
      <div v-html="body" class="comment-body" />
      <p class="timestamp">{{ formattedDate }}</p>
    </div>
    <div v-if="replies.length" class="comment-replies">
      Replies
    </div>
  </div>
</template>

And we should see the word “Replies” under the first two comments since they have replies.

Comment thread output showing replies

Now here’s where the benefit of recursion comes in. In Vue, so long as you named the component (which we did during setup with name: ‘Comment’), you can call it from within itself, just like any other component. This will make rendering our replies super easy.

Inside the .comment-replies div we just created, let’s replace the plain text with another Comment loop, just like we used in App.vue.

<div v-if="replies.length" class="comment-replies">
  <Comment
    v-for="reply in replies"
    :key="reply.id"
    v-bind="reply"
  />
</div>

And just like that, we have replies showing up!

Comment thread output showing replies

Using recursion to show those replies was super easy, but as we can see, there’s no great visual cue that we’re looking at nested replies. Let’s add some styles to help with that. To start, we’ll add some left padding, which sets the replies apart nicely. We’ll come back and add a little more later.

<style lang="css" scoped>
.comment-replies {
  padding-left: 3.5rem;
}
</style>

And because padding gets applied at each level, we get the immediate benefit of another level of visual nesting for free.

Comment thread output showing threaded replies

And now we’ve got a working comment thread tree! You can have as many nested levels as you’d like, though, for a straightforward user experience, we recommend not going deeper than two levels if possible.

We could stop here, but I’ll add a couple of other UI improvements to spruce things up.

Step 6: UI Bonuses.

Wouldn’t it be nice if our icon changed based on whether a comment was first-level or a reply? There are several ways to accomplish this, but today we’ll use a computed prop to determine which SVG string to use.

First, we’ll add a new prop to our Comment component to receive a comment type. By default, this will just be the string comment to note a first-level comment.

type: { type: String, required: false, default: 'comment' },

Then, in our recursive calls to Comment, we can pass a different type of reply to override the default.

<Comment v-for="reply in replies" :key="reply.id" v-bind="reply" type="reply" />

Now that our Comment component knows more about itself let’s act on that by rendering a different icon for replies.

We can choose which icon to render in a computed icon prop based on the type.

icon() {
  const commentIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>`;
  const replyIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>`;

  return this.type === 'reply' ? replyIcon : commentIcon;
},

And in our template, we can render the icon (I’ll do so in a span for simplicity).

<span v-html="icon"></span>

That looks better, but just one last thing. When there are many threads and replies, keeping track of where you are can be challenging. Using CSS pseudo-elements, we can add some lines to help keep things consistent.

Let’s update our Comment CSS:

.comment-replies {
  padding-left: 3.5rem;
  position: relative;
}

.comment-replies:before {
  background-color: SlateGray;
  content: '';
  height: calc(100% + 1rem);
  left: 1rem;
  position: absolute;
  top: 0;
  width: 1px;
}

.comment-replies:last-child:before {
  height: calc(100% - 1rem);
}

Next, we’ll add a quick tick to the Comment if it’s a reply. So in Comment.vue, let’s add a reply class to replies and another pseudo-element to add the tick.

<div class="comment" :class="{ reply: type === 'reply' }">

And in the CSS:

.comment.reply {
  position: relative;
}

.comment.reply:before {
  background-color: Silver;
  content: '';
  height: 1px;
  left: -2.5rem;
  position: absolute;
  top: 50%;
  width: 0.75rem;
}

Comment thread output showing threaded replies

And now it’s much easier to visualize which replies belong to each comment.

This somewhat confusing scenario was made easy with recursion. You can play with a working example at StackBlitz: https://stackblitz.com/edit/st-recursive-vue?file=src/App.vue.

I hope you found this example to be helpful. Let us know in the comments how you use recursion in Vue or JavaScript, and have fun building!

Loved the article? Hated it? Didn’t even read it?

We’d love to hear from you.

Reach Out

Comments (3)

Leave a comment

Leave a Reply

Your email address will not be published. Required fields are marked *

More Insights

View All