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 CLI, Vite, 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:
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.
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:
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 id
, author
, body
, timestamp
, and an array of replies
. The UI will need the author
, body
, 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="[]"
/>
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.
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.
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.
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!
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.
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;
}
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.
This article helped me a lot. Thank you!
Thanks..
It helped me a lot..
Exactly what I was looking for! Thanks!