Navigate back to the homepage

Creating a Product Hunt Clone with Vue.js and Tailwind CSS - Part 1

Kyrell Dixon
September 15th, 2019 · 11 min read

Who is this post for

This post aims to be as beginner-friendly as possible, but to get the most out of it, you should have experience working with Javascript fundamentals like objects, functions, arrays, conditionals, etc.

I include links to helpful resources that will go more into depth on specific concepts since this is more of a deep dive into the steps I took and the logic behind those decisions.

What will you be making

Time Heist Leaderboard

I’ve been getting a lot of questions on how to build a full stack Vue.js application using Vuex, Vue Router, and Firebase complete with authentication, so I decided to make one. Time Heist is a Product Hunt clone where users can upload trips instead of products.

This post walks through some fundamental Vue concepts like list rendering, computed properties, and props and how they can be used to build the leaderboard page of Time Heist. The final version is a dynamically rendered list of trips sorted by likes/upvotes.

Users will also be able to upvote trips as many times as they like, but this will eventually be limited to a single vote per trip.

You can view the finished project here.

Why Tailwind and Vue.js

Since no application is complete without styling, I decided to use Tailwind CSS. Tailwind is a “utility-first CSS framework,” that allows you to create a completely custom design unlike other popular CSS frameworks like Bootstrap and Material UI.

I decided to use Tailwind since I will be working with a custom design built in Sketch. Tailwind is a great CSS framework if you’re working with a designer because it allows you to easily integrate your color scheme and customize the Tailwind config to extend the functionality.

I am also using Vue.js because it is a very beginner-friendly Javascript framework relative to React.js and Angular. I considered using Svelte, but Vue.js has been out a bit longer so it has better documentation and is more widely used.

Getting started

Before getting started, I have to give credit to Danny Minutillo for creating the design and concept for Time Heist.

This post assumes you are using the yarn package manager, but all of the commands used can be replaced with the equivalent npm commands.

And of course, you will need Node to be able to run the app locally.

Create the project

This project was bootstrapped using the latest Vue CLI package. To install it, go to a bash shell and run:

1yarn global add @vue/cli # or npm i -g @vue/cli

From there you can create the project with the Vue CLI by running:

1vue create time-heist

This will open up a prompt asking you to pick a preset. This project uses the default settings, so hit enter after running the above command.

After setting up the default project, change directories and run it. You can do that with:

1cd time-heist
2yarn serve # or npm run serve

This will create a project named time-heist and automatically add it to version control. I used Git throughout the project, and it would be a good idea for you as well.

After creating the project, there are a few setup steps before getting into the code.

Setting up the project

Optionally add start script

For convenience, I added a start script to the package.json file so I could use my yarn alias, ys, to start the project.

This is an optional step since you can use yarn serve to run the project without adding the additional script. If you want to have the same setup, replace your scripts object with the one below.

1"scripts": {
2 "start": "vue-cli-service serve",
3 "serve": "vue-cli-service serve",
4 "build": "vue-cli-service build",
5 "lint": "vue-cli-service lint"
6},

Remove project boilerplate

Remove the extra assets including the HelloWorld.vue component, all the styling, the logo, and pretty much anything inside of App.vue. The directory structure should look like the one below after you are finished.

1.
2├── README.md
3├── babel.config.js
4├── package.json
5├── public
6│ ├── favicon.ico
7│ └── index.html
8├── src
9│ ├── App.vue
10│ └── main.js
11└── yarn.lock

The only thing left in the App.vue component is an h1 and a template tag just to ensure the styling is getting added correctly when Tailwind is imported.

App.vue

1<template>
2 <h1>App.vue</h1>
3</template>

Adding Tailwind CSS

One of the few drawbacks of using Tailwind is that at first requires some setup (which can be confusing). Setting it up optimally deserves its own post, but for the sake of this post, I added the steps I used to set up Tailwind with Vue.

First, install the library:

1yarn add tailwindcss # or npm i tailwindcss

From there you want to create a directory to keep the css files. Create a styles folder in assets and then add a tailwind.css file to it.

1mkdir src/assets/styles
2touch src/assets/styles/tailwind.css

Now you can add the Tailwind CSS imports to the file to have access to all the utility classes.

tailwind.css

1@tailwind base;
2@tailwind components;
3@tailwind utilities;

Import tailwind.css into main.js to include it in the Vue app. You can also add it to the App.vue file, but it’s important to make sure that it’s available to all the components. Adding to main.js will always work since this is the root of the Vue app.

1// main.js
2
3// ...other imports
4
5import './assets/styles/tailwind.css'

The tailwind styles require a build step, so to set that up you need to configure PostCSS to look for the tailwind files. First, create the PostCSS config in the root of the project with:

1touch postcss.config.js

Now you can add tailwindcss and autoprefixer to postcss.config.js.

1// postcss.config.js
2
3module.exports = {
4 plugins: [
5 require('tailwindcss'),
6 require('autoprefixer'),
7 ]
8}

The Vue CLI automatically installs and configures PostCSS in the package.json file, so you want to delete it so it won’t conflict with the PostCSS config file.

Remove the entire block in the package.json file containing the code block below.

1"postcss": {
2 "plugins": {
3 "autoprefixer": {}
4 }
5 },

The package.json file should now look like:

1{
2 "name": "time-heist",
3 "version": "0.1.0",
4 "private": true,
5 "scripts": {
6 "start": "vue-cli-service serve",
7 "serve": "vue-cli-service serve",
8 "build": "vue-cli-service build",
9 "lint": "vue-cli-service lint"
10 },
11 "dependencies": {
12 "core-js": "^2.6.5",
13 "tailwindcss": "^1.1.2",
14 "vue": "^2.6.10"
15 },
16 "devDependencies": {
17 "@vue/cli-plugin-babel": "^3.11.0",
18 "@vue/cli-plugin-eslint": "^3.11.0",
19 "@vue/cli-service": "^3.11.0",
20 "babel-eslint": "^10.0.1",
21 "eslint": "^5.16.0",
22 "eslint-plugin-vue": "^5.0.0",
23 "vue-template-compiler": "^2.6.10"
24 },
25 "eslintConfig": {
26 "root": true,
27 "env": {
28 "node": true
29 },
30 "extends": [
31 "plugin:vue/essential",
32 "eslint:recommended"
33 ],
34 "rules": {},
35 "parserOptions": {
36 "parser": "babel-eslint"
37 }
38 },
39 "browserslist": [
40 "> 1%",
41 "last 2 versions"
42 ]
43}

If it doesn’t, you can replace the contents with the text above, delete your node_modules and yarn.lock files, and run yarn to ensure you have the same setup.

The last step for configuring Tailwind is to create the tailwind.config.js file in the root folder of the project. To create it run:

1npx tailwind init

This will create the file with some boilerplate tailwind config info.

tailwind.config.js

1module.exports = {
2 theme: {
3 extend: {}
4 },
5 variants: {},
6 plugins: []
7}

There isn’t anything to add to it now, but this file can be configured to add or remove functionality from Tailwind.

With that out of the way, you are now ready to create the Leaderboard page!

Creating the Leaderboard Page

Just add HTML

If you’re comfortable working with HTML and CSS, then this part will be fun. I had the designs open as I styled the app, but feel free to reach out to me on Twitter for the design. You can also just reference the finished app.

There’s not much that needs to be broken down in this section since it is just HTML and CSS. If you are curious what any specific class is doing, just copy the class and search it in the Tailwind docs.

Below is my initial App.vue file complete with the navbar and a single Leaderboard card.

1<template>
2 <div class="bg-gray-200 h-screen">
3 <nav class="bg-black text-white flex justify-between items-center px-4 md:px-8 py-4">
4 <p class="uppercase tracking-widest">Time Heist</p>
5 <div>
6 <button class="uppercase text-xs">Log In</button>
7 <button class="uppercase text-xs px-4 py-2 bg-gradient rounded ml-4">Sign Up</button>
8 </div>
9 </nav>
10
11 <section>
12 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
13 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
14 <article class="flex justify-between items-center bg-white rounded p-6 uppercase">
15 <div class="flex">
16 <img class="h-32 hidden md:block" src="https://via.placeholder.com/150" alt="Dream trip">
17 <div class="flex flex-col justify-between ml-4">
18 <div class="mb-4">
19 <p class="font-bold text-sm">Title</p>
20 <p>City</p>
21 </div>
22 <div class="flex items-center">
23 <span class="text-xs text-gray-700 mr-2">Posted by</span>
24 <img class="h-4 rounded-full" src="https://randomuser.me/api/portraits/men/86.jpg" alt="Profile">
25 </div>
26 </div>
27 </div>
28 <button class="font-bold flex flex-col py-4 px-8 bg-gray-100 border border-gray-300 rounded">
29 <span role="img" aria-label="up arrow">🔝</span>
30 <span>23</span>
31 </button>
32 </article>
33 </div>
34 </section>
35 </div>
36</template>
37
38<style scoped>
39 .bg-gradient {
40 background: linear-gradient(to top right,#af35f1, #fa9a5e);
41 }
42</style>

I only needed to create one class to add a custom gradient for the button backgrounds. Everything else is completely Tailwind.

I used Placeholder.com as thumbnails for the Trip image and Randomuser.me to get a profile picture. Later these will be replaced by actual images.

Currently the App.vue component is getting a bit bloated, so it’s time for a refactor!

Refactor from app to components

The two main components that are good candidates for an initial refactor, the navbar and the leaderboard card.

To refactor these components make two new files called LeaderboardCard.vue and Navbar.vue in a components directory. You can simply add them with your text editor or IDE, or run the commands below:

1mkdir src/components; cd $_
2touch LeaderboardCard.vue Navbar.vue

From here it’s just a matter of cutting and pasting from App.vue into each of the separate files and importing them back into the App component. Your files should look like:

App.vue

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card />
8 </div>
9 </section>
10 </div>
11</template>
12
13<script>
14import Navbar from './components/Navbar'
15import LeaderboardCard from './components/LeaderboardCard'
16
17export default {
18 components: {
19 'navbar': Navbar,
20 'leaderboard-card': LeaderboardCard,
21 }
22}
23</script>

LeaderboardCard.vue

1<template>
2 <article class="flex justify-between items-center bg-white rounded p-6 uppercase mb-6">
3 <div class="flex">
4 <img class="h-32 hidden md:block" src="https://via.placeholder.com/150" alt="Dream trip">
5 <div class="flex flex-col justify-between ml-4">
6 <div class="mb-4">
7 <p class="font-bold text-sm">Title</p>
8 <p>City</p>
9 </div>
10 <div class="flex items-center">
11 <span class="text-xs text-gray-700 mr-2">Posted by</span>
12 <img class="h-12 rounded-full" src="https://randomuser.me/api/portraits/men/80.jpg" alt="Profile">
13 </div>
14 </div>
15 </div>
16 <button class="font-bold flex flex-col py-4 px-8 bg-gray-100 border border-gray-300 rounded">
17 <span role="img" aria-label="up arrow">🔝</span>
18 <span>23</span>
19 </button>
20 </article>
21</template>

Navbar.vue

1<template>
2 <nav class="bg-black text-white flex justify-between items-center px-4 md:px-8 py-4">
3 <p class="uppercase tracking-widest">Time Heist</p>
4 <div>
5 <button class="uppercase text-xs">Log In</button>
6 <button class="uppercase text-xs px-4 py-2 bg-gradient rounded ml-4">Sign Up</button>
7 </div>
8 </nav>
9</template>
10
11<style scoped>
12 .bg-gradient {
13 background: linear-gradient(to top right,#af35f1, #fa9a5e);
14 }
15</style>

The components also had to be registered to the App.vue so they could be added as tags in the template.

The nice thing about having separate components is that it makes it easy to duplicate a card to simulate having multiple trips. Duplicate the leaderboard-card tag a few times so you have an App.vue template that looks like:

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card />
8 <leaderboard-card />
9 <leaderboard-card />
10 </div>
11 </section>
12 </div>
13</template>

Currently, all the data in the leaderboard cards is hard-coded. To make it more dynamic, we’ll use Vue Props.

Adding dynamic data to a component with props

Before you can add props, you have to decide what data you want to pass in dynamically. In this case, the trip title, city, and likes are all good candidates.

To add props to the cards, you create an attribute the same way you add a class to a tag by passing the name of the prop followed by a string containing the prop value.

I added some fake trip data so my trips so the template of the App.vue component now looks like:

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card title="The Best Vacation Ever" city="Paris" likes="57" />
8 <leaderboard-card title="Digital Nomad Paradise" city="Chiang Mai" likes="42" />
9 <leaderboard-card title="Becoming Batman" city="Gotham" likes="23" />
10 </div>
11 </section>
12 </div>
13</template>

By itself, this won’t change anything. You also have to update the LeaderboardCard.vue file. There are two main steps when adding props to a child component:

  1. Add a props key with an array or object containing the props,
  2. and Add the prop to the template in double curly brackets

You can see this in the LeaderboardCard.vue file:

1<template>
2 <article class="flex justify-between items-center bg-white rounded p-6 uppercase mb-6">
3 <div class="flex">
4 <img class="h-32 hidden md:block" src="https://via.placeholder.com/150" alt="Dream trip">
5 <div class="flex flex-col justify-between ml-4">
6 <div class="mb-4">
7 <p class="font-bold text-sm">{{ title }}</p>
8 <p>{{ city }}</p>
9 </div>
10 <div class="flex items-center">
11 <span class="text-xs text-gray-700 mr-2">Posted by</span>
12 <img class="h-12 rounded-full" src="https://randomuser.me/api/portraits/men/86.jpg" alt="Profile">
13 </div>
14 </div>
15 </div>
16 <button class="font-bold flex flex-col py-4 px-8 bg-gray-100 border border-gray-300 rounded">
17 <span role="img" aria-label="up arrow">🔝</span>
18 <span>{{ likes }}</span>
19 </button>
20 </article>
21</template>
22
23<script>
24export default {
25 props: [
26 "title",
27 "city",
28 "likes",
29 ]
30}
31</script>

If you want to quickly add the props to test functionality, adding them in an array is a gray way to get them working. An even better way to use props and a Vue best practice is to use prop types.

Adding prop types

Vue Prop Types will cause warnings to display in the console when the type of a value is invalid. This can help you catch errors early on in the development process.

The Vue props documentation shows a full list of all the available prop types that can be used.

In this case, the title and city should both be a String, and the likes should be the Number type.

To add in types, you change props to an object where the keys are the name of the props, and the values are the prop types.

LeaderboardCard.vue

1<script>
2export default {
3 props: {
4 title: String,
5 city: String,
6 likes: Number,
7 }
8}
9</script>

Another best practice with prop types is to set either a default value or a required flag. You should only add one or the other since you don’t need a default value if one should always be passed in.

For this example, the props should all be required. The script tag for LeaderboardCard.vue should now look like:

1<script>
2export default {
3 props: {
4 title: {
5 type: String,
6 required: true,
7 },
8 city: {
9 type: String,
10 required: true,
11 },
12 likes: {
13 type: Number,
14 required: true,
15 },
16 }
17}
18</script>

If you check the console, you may notice that it is now throwing warnings for the likes prop. This is because the App.vue component is passing a String type to the likes prop instead of a Number.

It may look like a number is being passed in, but HTML attributes are always passed in as a string. To fix the error, the leaderboard-card tags in App.vue will have to be modified.

You can use the v-bind syntax to correctly pass data to the likes prop. To fix the error change likes="23" to :likes="23". Adding the ”:” to the likes prop is short-hand for v-bind:likes.

When a value is bound, Vue changes the data passed into the prop into it’s Javascript equivalent. In this case, "23" becomes the number 23. If we still wanted to pass a string, you could do so with :likes="'23'".

Now that the data is being dynamically passed in with props, we can remove some of the code duplication and dynamically render the list of trips.

Dynamically rendering lists with v-for

To render the list dynamically, we’ll need some data to add to each component. I extracted the data that was being passed into each component individually into a trips array by passing it to data in a function as shown below.

App.vue

1<script>
2import Navbar from './components/Navbar'
3import LeaderboardCard from './components/LeaderboardCard'
4
5export default {
6 components: {
7 'navbar': Navbar,
8 'leaderboard-card': LeaderboardCard,
9 },
10 data: function() {
11 return {
12 trips: [
13 {
14 title: "The Best Vacation Ever",
15 city: "Paris",
16 likes: 57,
17 },
18 {
19 title: "Digital Nomad Paradise",
20 city: "Chiang Mai",
21 likes: 42,
22 },
23 {
24 title: "Becoming Batman",
25 city: "Gotham",
26 likes: 23,
27 },
28 ],
29 }
30 },
31}
32</script>

To iterate over the array and display it correctly, v-for is used on the leaderboard card like below.

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card
8 v-for="trip in trips"
9 :key="trip.title"
10 :title="trip.title"
11 :city="trip.city"
12 :likes="trip.likes"
13 />
14 </div>
15 </section>
16 </div>
17</template>

Let’s break this down.

The v-for attribute is used in the tag you want to repeat, so here it is passed into the leaderboard-card tag. The value of the v-for is trip in trips since we want to loop through the trips array that was added to data.

On each iteration of the loop, a trip object is extracted and used to pass in data to each prop. The :title and :city attributes are passed trip.title and trip.city respectively so that the trip values can be correctly passed in.

The new :key prop is required to be a unique value so Vue can tell each component apart when using a v-for loop. The trip title is functioning as the key currently since there are only a few items and it is easy to verify they are unique.

In an actual application, this ID would come from the database or API that is in use. To simulate that, we can add an id to each trip object in the trips array.

To make this work a little more nicely, add an ID to each trip in the trips array and pass the whole trip as a prop. The files should now look like:

App.vue

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card
8 v-for="trip in trips"
9 :key="trip.id"
10 :title="trip.title"
11 :city="trip.city"
12 :likes="trip.likes"
13 />
14 </div>
15 </section>
16 </div>
17</template>

Extracting out each trips data in the for loop isn’t necessary since the entire trip object could be passed to the leaderboard card as a prop. After this final refactor, the files should look like:

App.vue

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card
8 v-for="trip in trips"
9 :key="trip.id"
10 :trip="trip"
11 />
12 </div>
13 </section>
14 </div>
15</template>
16
17<script>
18import Navbar from './components/Navbar'
19import LeaderboardCard from './components/LeaderboardCard'
20
21export default {
22 components: {
23 'navbar': Navbar,
24 'leaderboard-card': LeaderboardCard,
25 },
26 data: function() {
27 return {
28 trips: [
29 {
30 id: 1,
31 title: "The Best Vacation Ever",
32 city: "Paris",
33 likes: 57,
34 },
35 {
36 id: 2,
37 title: "Digital Nomad Paradise",
38 city: "Chiang Mai",
39 likes: 42,
40 },
41 {
42 id: 3,
43 title: "Becoming Batman",
44 city: "Gotham",
45 likes: 23,
46 },
47 ],
48 }
49 },
50}
51</script>

LeaderboardCard.vue

1<template>
2 <article class="flex justify-between items-center bg-white rounded p-6 uppercase mb-6">
3 <div class="flex">
4 <img class="h-32 hidden md:block" src="https://via.placeholder.com/150" alt="Dream trip">
5 <div class="flex flex-col justify-between ml-4">
6 <div class="mb-4">
7 <p class="font-bold text-sm">{{ trip.title }}</p>
8 <p>{{ trip.city }}</p>
9 </div>
10 <div class="flex items-center">
11 <span class="text-xs text-gray-700 mr-2">Posted by</span>
12 <img class="h-12 rounded-full" src="https://randomuser.me/api/portraits/men/86.jpg" alt="Profile">
13 </div>
14 </div>
15 </div>
16 <button class="font-bold flex flex-col py-4 px-8 bg-gray-100 border border-gray-300 rounded">
17 <span role="img" aria-label="up arrow">🔝</span>
18 <span>{{ trip.likes }}</span>
19 </button>
20 </article>
21</template>
22
23<script>
24export default {
25 props: {
26 trip: {
27 type: Object,
28 required: true
29 }
30 },
31}
32</script>

Add that’s it! This section covered a lot, so it’s okay if it was a little confusing.

The main takeaways here are:

  • The v-for syntax is used to iterate over the trips and create a new leaderboard-card tag for each trip in trips.
  • When using v-for, a unique key should also be added as a prop.
  • A single trip object is a cleaner and more scalable way to pass in trip data than passing each prop individually.

For a more in-depth look into list rendering, you can check out the Vue docs.

With that out of the way, it is time to add the ability for users to upvote their favorite trip and make this more of a leaderboard!

Adding upvote functionality

First, add a new required upVote prop with the Function type to the props in LeaderboardCard.vue.

1<script>
2export default {
3 props: {
4 trip: {
5 type: Object,
6 required: true,
7 },
8 upVote: {
9 type: Function,
10 required: true,
11 },
12 },
13}
14</script>

Now add an @click event handler to the upvote button and pass it the upVote function. The leaderboard component should now look like:

1<template>
2 <article class="flex justify-between items-center bg-white rounded p-6 uppercase mb-6">
3 <div class="flex">
4 <img class="h-32 hidden md:block" src="https://via.placeholder.com/150" alt="Dream trip">
5 <div class="flex flex-col justify-between ml-4">
6 <div class="mb-4">
7 <p class="font-bold text-sm">{{ trip.title }}</p>
8 <p>{{ trip.city }}</p>
9 </div>
10 <div class="flex items-center">
11 <span class="text-xs text-gray-700 mr-2">Posted by</span>
12 <img class="h-12 rounded-full" src="https://randomuser.me/api/portraits/men/86.jpg" alt="Profile">
13 </div>
14 </div>
15 </div>
16 <button @click="upVote(trip.id)" class="font-bold flex flex-col py-4 px-8 bg-gray-100 border border-gray-300 rounded">
17 <span role="img" aria-label="up arrow">🔝</span>
18 <span>{{ trip.likes }}</span>
19 </button>
20 </article>
21</template>

The @click handler is being assigned upVote(trip.id) here so that clicking the button will increment the appropriate trip.

Currently, there is no upVote function being passed in as a prop, so let’s add it to App.vue and pass it in.

App.vue

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card
8 v-for="trip in trips"
9 :key="trip.id"
10 :trip="trip"
11 :upVote="upVote"
12 />
13 </div>
14 </section>
15 </div>
16</template>
17
18<script>
19import Navbar from './components/Navbar'
20import LeaderboardCard from './components/LeaderboardCard'
21
22export default {
23 components: {
24 'navbar': Navbar,
25 'leaderboard-card': LeaderboardCard,
26 },
27 data: function() {
28 return {
29 trips: [
30 {
31 id: 1,
32 title: "The Best Vacation Ever",
33 city: "Paris",
34 likes: 57,
35 },
36 {
37 id: 2,
38 title: "Digital Nomad Paradise",
39 city: "Chiang Mai",
40 likes: 42,
41 },
42 {
43 id: 3,
44 title: "Becoming Batman",
45 city: "Gotham",
46 likes: 23,
47 },
48 ],
49 }
50 },
51 methods: {
52 upVote: function(id) {
53 this.trips = this.trips.map(trip => {
54 if (trip.id === id) {
55 return {
56 ...trip,
57 likes: trip.likes + 1
58 }
59 }
60 return trip;
61 });
62 }
63 }
64}
65</script>

So what’s going on here?

The leaderboard-card tag is being passed the new upVote function as a prop as shown here:

1<leaderboard-card
2 v-for="trip in trips"
3 :key="trip.id"
4 :trip="trip"
5 :upVote="upVote"
6/>

Then you have the upVote method itself:

1methods: {
2 upVote: function(id) {
3 this.trips = this.trips.map(trip => {
4 if (trip.id === id) {
5 return {
6 ...trip,
7 likes: trip.likes + 1
8 }
9 }
10 return trip;
11 });
12 }
13}

The first thing to understand is the .map part of this.trips.map. Mapping comes from the world of functional programming and is simply a way to loop through an array and return a new array with some modification to the values.

In this case the loop is checking if a trips ID matches the ID passed into the function in the trips.id === id line. The id parameter comes from the currently clicked button.

If the IDs match, then this is the element we want to upvote. To upvote an object, you have to create a brand new object with the likes increased by 1. That is being handled by:

1return {
2 ...trip,
3 likes: trip.likes + 1
4}

The ...trip line uses the spread syntax to copy all of the existing trip values into this new object. This line is essentially the same as saying:

1return {
2 id: trip.id,
3 title: trip.title,
4 city: trip.city,
5 likes: trip.likes + 1
6}

So if the IDs match, the trip is replaced. Otherwise, it just returns the original trip unmodified.

When the map finishes, it returns a new trips array with the likes incremented for the trip whose upvote button was clicked. The new array is assigned to this.trips.

A summary of the overall flow is:

  1. A button is clicked
  2. upvote(trip.id) is called with trip.id being the id for the trip associated with the current button.
  3. The trips are looped through with this.trips.map
  4. The trip with the matching id is replaced with an identical object except the likes are incremented by 1
  5. this.trips is assigned the updated trips array with the correct trip upvoted.

After that, Vue handles the rest! It notices the change to the array and re-renders the leaderboard-card tags.

Now that trips can be upvoted, Vue should automatically sort the trips so the most upvoted trip is on top.

Sorting trips with computed properties

To sort the trips, add the following code to App.vue:

1// methods: {
2// ...
3//},
4computed: {
5 sortedTrips: function() {
6 return [...this.trips].sort((a, b) => b.likes - a.likes)
7 }
8},

To break down this understandably cryptic syntax, we’ll start from the sortedTrips key. All values inside of the computed object should be functions that return a new object. However, you can think of sortedTrips as just the return value of the function. So what exactly is being returned here?

Starting at the beginning of the return statement, you’ll see [...this.trips]. The spread (...) operator is being used to extract all the existing values of the this.trips array into a new array.

I had to do this because if you try to sort the original this.trips array you’ll get the error: error: Unexpected side effect in "sortedTrips" computed property. This is because sort attempts to modify the original trips array instead of returning a new array with all the values sorted. To overcome that, you make a copy of this.trips and sort that array instead.

To sort the new array, the .sort function is being used. Javascript requires you to pass a comparison function to sort so that it knows what to sort by. An ES6 arrow function is being passed as shown by the:

1(a, b) => b.likes - a.likes

This comparison function is taken straight from the Mozilla Sort documenation with a slight modification.

The example used to sort numbers in ascending order is:

1let numbers = [4, 2, 5, 1, 3];
2numbers.sort((a, b) => a - b);
3console.log(numbers);
4
5// [1, 2, 3, 4, 5]

I used b - a since the values should be sorted in descending order with the most liked trips listed first. Since the trips are sorted by likes b.likes and a.likes are subtracted instead of attempting to subtract the objects with b - a.

After sorting the trips, the computed property sortedTrips can be used in the for loop. The final App.vue value should look like:

1<template>
2 <div class="bg-gray-200 h-screen">
3 <navbar />
4 <section>
5 <div class="mx-4 lg:mx-auto mt-20 max-w-4xl">
6 <h1 class="font-bold uppercase tracking-wide mb-8">Leaderboard</h1>
7 <leaderboard-card
8 v-for="trip in sortedTrips"
9 :key="trip.id"
10 :trip="trip"
11 :upVote="upVote"
12 />
13 </div>
14 </section>
15 </div>
16</template>
17
18<script>
19import Navbar from './components/Navbar'
20import LeaderboardCard from './components/LeaderboardCard'
21
22export default {
23 components: {
24 'navbar': Navbar,
25 'leaderboard-card': LeaderboardCard,
26 },
27 data: function() {
28 return {
29 trips: [
30 {
31 id: 2,
32 title: "Digital Nomad Paradise",
33 city: "Chiang Mai",
34 likes: 42,
35 },
36 {
37 id: 1,
38 title: "The Best Vacation Ever",
39 city: "Paris",
40 likes: 57,
41 },
42 {
43 id: 3,
44 title: "Becoming Batman",
45 city: "Gotham",
46 likes: 23,
47 },
48 ],
49 }
50 },
51 computed: {
52 sortedTrips: function() {
53 return [...this.trips].sort((a, b) => b.likes - a.likes)
54 }
55 },
56 methods: {
57 upVote: function(id) {
58 this.trips = this.trips.map(trip => {
59 if (trip.id === id) {
60 return {
61 ...trip,
62 likes: trip.likes + 1
63 }
64 }
65 return trip;
66 });
67 }
68 }
69}
70</script>

After the trips are sorted, Vue will now automatically sort the array whenever a trip has more likes than another and move it up the list.

With that finished, the Time Heist leaderboard is finished!

Finishing up and next steps

If you’ve been adding your code to git, it should be easy to push to deploy it to Netlify in less than 5 minutes. Mine can be found here.

I went back and added a few more styles, so if you want to see the full repo, check out the leaderboard branch of the repo.

The next steps are to add the rest of the pages for signing up, logging in, uploading trips, trip details, and a landing page to keep it all together.

If you have any questions or feedback, feel free to reach out to me on Twitter!

Join our email list and get notified about new content

Be the first to receive our latest content with the ability to opt-out at anytime. We promise to not spam your inbox or share your email with any third parties.

More articles from Kyrell Dixon

How to Setup an Express.js Server in Node.js

This is a completely beginner friendly tutorial on how to setup a web server with Node.js using the Express framework.

August 18th, 2019 · 9 min read

React From Scratch: React 16.7 + Webpack 4 + Babel 7 Tutorial

Branch out from `create-react-app` and learn how to build your own custom React configuration with the most up to date versions of React, Webpack, and Babel.

February 4th, 2019 · 4 min read
© 2019 Kyrell Dixon
Link to $https://twitter.com/kyrelldixonLink to $https://github.com/kyrelldixonLink to $https://instagram.com/kyrell.dixonLink to $https://www.linkedin.com/in/kyrell-dixon/