Build a realtime app with Vue.js

Introduction

In this tutorial, I will walk you through getting started with a Vue.js 2.0 app, and adding realtime functionality to it with Pusher Channels. The sample app we will be building is a movie review app called “revue”.

Here is what the final app will look like:

realtime-app-vuejs-demo

You can find the complete code hosted on Github.

Setting up with Vue-cli

Vue-cli is a great command line tool for scaffolding Vue.js projects, so we don’t have to spend too much time on configuration, and can jump right into writing code!

If you haven’t already, install vue-cli:

npm install -g vue-cli

We will create a project with the webpack template, and install the dependencies with this set of commands:

1vue init webpack revue
2cd revue
3npm install

Webpack is a build tool that helps us do a bunch of things like parse Vue single file components, and convert our ES6 code to ES5 so we don’t have to worry about browser compatibility. You can check here for more details about the webpack template.

To run the app:

npm run dev

We can also optionally include Foundation in the index.html file to take advantage of some preset styling:

1<!-- ./index.html -->
2<!DOCTYPE html>
3<html>
4  <head>
5    <meta charset="utf-8">
6    <!-- import foundation -->
7    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/foundation/6.3.1/css/foundation.min.css"> 
8    <title>revue</title>
9  </head>
10  <body>
11    <div id="app"></div>
12    <!-- built files will be auto injected -->
13  </body>
14</html>

Creating the movie review app

We will get started by creating the movie and review components of the app:

1touch ./src/components/Movie.vue
2touch ./src/components/Reviews.vue

It is helpful to know that one of the things that makes Vue so powerful is its components, much like other modern JavaScript frameworks. A typical app should be a series of components built on top of one another. This keeps our app modular, and helps make different parts of the app reusable.

Searching and retrieving a movie

To show the movie to be reviewed, we will create a simple form which we will use to fetch a movie from the Netflix Roulette public database API:

1<!-- ./src/components/Movie.vue -->
2<template>
3  <div class="container">
4    <div class="row">
5      <form @submit.prevent="fetchMovie()">
6        <div class="columns large-8">
7          <input type="text" v-model="title">
8        </div>
9        <div class="columns large-4">
10          <button type="submit" :disabled="!title" class="button expanded">
11            Search titles
12          </button>
13        </div>
14      </form>
15    </div>
16    <!-- /search form row -->
17  </div>
18  <!-- /container -->
19</template>

In the above code, we created a form, and specified a custom fetchMovie() event handler on form submit. Don’t worry, we will define this handler in a bit.

The @submit directive is shorthand for v-on:submit. The v-on directive is used to listen to DOM events and run actions (or handlers) when they’re triggered. The .prevent modifier helps us abstract the need to write event.preventDefault() in the handler logic… which is pretty cool.

You can read more on Vue.js event handlers here.

We also use the v-model directive to bind the value of the text input to title. And finally we bind the disabled attribute of the button such that it is set to true if title is absent, and vice versa. :disabled is shorthand for v-bind:disabled.

Next we define the methods and data values for the component:

1<!-- ./src/components/Movie.vue -->
2<script>
3// define the external API URL
4const API_URL = 'https://netflixroulette.net/api/api.php'
5// Helper function to help build urls to fetch movie details from title
6function buildUrl (title) {
7  return `${API_URL}?title=${title}`
8}
9
10export default {
11  name: 'movie', // component name
12  data () {
13    return {
14      title: '',
15      error_message: '',
16      loading: false, // to track when app is retrieving data
17      movie: {}
18    }
19  },
20  methods: {
21    fetchMovie () {
22      let title = this.title
23      if (!title) {
24        alert('please enter a title to search for')
25        return
26      }
27      this.loading = true 
28      fetch(buildUrl(title))
29      .then(response => response.json())
30      .then(data => {
31        this.loading = false
32        this.error_message = ''
33        if (data.errorcode) {
34          this.error_message = `Sorry, movie with title '${title}' not found. Try searching for "Fairy tail" or "The boondocks" instead.`
35          return
36        }
37        this.movie = data
38      }).catch((e) => {
39        console.log(e)
40      })
41    }
42  }
43}
44</script>

In the above code, after defining the external URL we want to query to get movies from, we specify the key Vue options we need for the component:

  • data: this specifies properties we’ll be needing in our component. Note that in a regular Vue construct it is an object, but it has to be returned as a function in a component.
  • methods: this specifies the methods we are using in the component. For now, we only define one method — the fetchMovie() method to retrieve movies. Notice we also use the Fetch API for retrieving results, to keep things simple.

Note: The JavaScript Fetch API is great for making AJAX requests, although it requires a polyfill for older browsers. A great alternative is axios.

Next, we can add the code to display the movie, and show a notice when the movie title isn’t found, inside the <template>:

1<!-- ./src/components/Movie.vue -->
2<template>
3<!-- // ... -->
4<div v-if="loading" class="loader">
5  <img src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/0.16.1/images/loader-large.gif" alt="loader">
6</div>
7
8<div v-else-if="error_message">
9  <h3>{{ error_message }}</h3>
10</div>
11
12<div class="row" v-else-if="Object.keys(movie).length !== 0" id="movie">
13  <div class="columns large-7">
14    <h4> {{ movie.show_title }}</h4>
15    <img :src="movie.poster" :alt="movie.show_title">
16  </div>
17  <div class="columns large-5">
18    <p>{{ movie.summary }}</p>
19    <small><strong>Cast:</strong> {{ movie.show_cast }}</small>
20  </div>
21</div>
22</template>

We use double curly braces for text interpolation. We also introduced new v-if, v-else-if and v-else directives, which we use to conditionally render elements.

We can add some optional styling at the bottom of the component:

1<!-- ./src/components/Movie.vue -->
2<!-- Add "scoped" attribute to limit CSS to this component only -->
3<style scoped>
4#movie {
5  margin: 30px 0;
6}
7.loader {
8  text-align: center;
9}
10</style>

The final Movie.vue file will look like this.

Retrieving and writing movie reviews

Next, we will edit the review component, which will contain the logic and view for reviews, using the same Single File Component approach.

First, we use a v-for directive to loop through the available reviews for a movie and display it in the template:

1<!-- ./src/components/Review.vue -->
2<template>
3  <div class="container">
4    <h4 class="uppercase">reviews</h4>
5    <div class="review" v-for="review in reviews">
6      <p>{{ review.content }}</p>
7      <div class="row">
8        <div class="columns medium-7">
9          <h5>{{ review.reviewer }}</h5>
10        </div>
11        <div class="columns medium-5">
12          <h5 class="pull-right">{{ review.time }}</h5>
13        </div>
14      </div>
15    </div>
16  </div>
17</template>
18
19<script>
20const MOCK_REVIEWS = [
21  {
22    movie_id: 7128,
23    content: 'Great show! I loved every single scene. Defintiely a must watch!',
24    reviewer: 'Jane Doe',
25    time: new Date().toLocaleDateString()
26  }
27]
28export default {
29  name: 'reviews',
30  data () {
31    return {
32      mockReviews: MOCK_REVIEWS,
33      movie: null,
34      review: {
35        content: '',
36        reviewer: ''
37      }
38    }
39  },
40  computed: {
41    reviews () {
42      return this.mockReviews.filter(review => {
43        return review.movie_id === this.movie
44      })
45    }
46  }
47}
48</script>

We create MOCK_REVIEWS to mock the available reviews, gotten from a resource, for example, an API. Then, we use a computed property to filter out the reviews for a particular movie. This would typically be gotten from the API or resource.

Next, we add a form and method for adding a new review:

1<!-- ./src/components/Review.vue -->
2<template>
3  <div class="container">
4    <!-- //... -->
5    <div class="review-form" v-if="movie">
6      <h5>add new review.</h5>
7      <form @submit.prevent="addReview">
8        <label>
9          Review
10          <textarea v-model="review.content" cols="30" rows="5"></textarea>
11        </label>
12        <label>
13          Name
14          <input v-model="review.reviewer" type="text">
15        </label>
16        <button :disabled="!review.reviewer || !review.content" type="submit" class="button expanded">Submit</button>
17      </form>
18    </div>
19    <!-- //... -->   
20  </div> 
21</template>
22
23<script>
24export default {
25  // ..
26  methods: {
27    addReview () {
28      if (!this.movie || !this.review.reviewer || !this.review.content) {
29        return
30      }
31      let review = {
32        movie_id: this.movie, 
33        content: this.review.content, 
34        reviewer: this.review.reviewer, 
35        time: new Date().toLocaleDateString()
36      }
37      this.mockReviews.unshift(review)
38    }
39  },
40  //...
41}
42</script>

We can add some optional styling at the bottom of the component:

1<!-- ./src/components/Review.vue -->
2<!-- Add "scoped" attribute to limit CSS to this component only -->
3<style scoped>
4  .container {
5    padding: 0 20px;
6  }
7  .review {
8    border:1px solid #ddd;
9    font-size: 0.95em;
10    padding: 10px;
11    margin: 15px 0 5px 0;
12  }
13  .review h5 {
14    text-transform: uppercase;
15    font-weight: bolder;
16    font-size: 0.7em
17  }
18  .pull-right {
19    float: right;
20  }
21  .review-form {
22    margin-top: 30px;
23    border-top: 1px solid #ddd;
24    padding: 15px 0 0 0;
25  }
26</style>

To fetch and post reviews, we need to use the movie identifier, which is gotten in the Movie component. Thankfully, component-to-component communication can be done really easily in Vue.

Component-to-component communication

As recommended in the official documentation, we can create a new Vue instance and use it as a message bus. The message bus is an object that components can emit and listen to events on. In a larger application, a more robust state management solution like Vuex is recommended.

Creating the message bus:

touch ./src/bus.js
1// ./src/bus.js
2import Vue from 'vue'
3const bus = new Vue()
4
5export default bus

To emit an event once a movie is found, we update the fetchMovies() method:

1<!-- ./src/components/Movie.vue -->
2import bus from '../bus'
3
4export default {
5  // ...
6  methods: {
7    fetchMovie (title) {
8      this.loading = true
9      fetch(buildUrl(title))
10      .then(response => response.json())
11      .then(data => {
12        this.loading = false
13        this.error_message = ''
14        bus.$emit('new_movie', data.unit) // emit `new_movie` event
15        if (data.errorcode) {
16          this.error_message = `Sorry, movie with title '${title}' not found. Try searching for "Fairy tail" or "The boondocks" instead.`
17          return
18        }
19        this.movie = data
20      }).catch(e => { console.log(e) })
21    }
22  }
23}

Listening for the event in the Review component, in the created hook:

1<!-- ./src/components/Review.vue -->
2<script>
3import bus from '../bus'
4export default {
5  // ...
6  created () {
7    bus.$on('new_movie', movieId => {
8      this.movie = movieId
9    })
10  },
11  // ...
12}
13</script>

In the above code, we specify that whenever the new_movie event is fired, we set the movie property to be the value of the movieId that is broadcast by the event.

For a better understanding of the Vue lifecycle hooks, you can check out the official documentation on the subject.

Finally to complete our base app, we register our components in App.vue, and display the templates:

1<!-- ./src/App.vue -->
2<template>
3  <div id="app">
4    <div class="container">
5      <div class="heading">
6        <h2>revue.</h2>
7        <h6 class="subheader">realtime movie reviews with Vue.js and Pusher.</h6>
8      </div>
9      <div class="row">
10        <div class="columns small-7">
11          <movie></movie>
12        </div>
13        <div class="columns small-5">
14          <reviews></reviews>
15        </div>
16      </div>
17    </div>
18  </div>
19</template>
20
21<script>
22  import Movie from './components/Movie'
23  import Reviews from './components/Reviews'
24
25  export default {
26    name: 'app',
27    components: {
28      Movie, Reviews
29    }
30  }
31</script>
32
33<style>
34  #app .heading {
35    font-family: 'Avenir', Helvetica, Arial, sans-serif;
36    -webkit-font-smoothing: antialiased;
37    -moz-osx-font-smoothing: grayscale;
38    text-align: center;
39    color: #2c3e50;
40    margin: 60px 0 30px;
41    border-bottom: 1px solid #eee;
42  }
43</style>

Now, we can run the app, and see the basic functionalities of retrieving movies and adding reviews!

npm run dev
realtime-app-vuejs-demo2

Note: To retrieve movies from the public API, the movie titles have to be typed in full. Also, the available movies are limited, so don’t be too disappointed if you don’t find a title you search for. :)

Adding realtime updates to the app with Pusher Channels

We can add realtime functionality to our app so that whenever a review is added, it is updated in real time to all users viewing that movie.

We will set up a simple backend where we can process post requests with new reviews, and broadcast an event via Pusher whenever a review is added.

Channels setup

Head over to Pusher and register for a free account, if you don’t already have one. Then create a Channels app on the dashboard, and copy out the app credentials (App ID, Key, Secret and Cluster). It is super straight-forward.

Backend setup and broadcasting an event

We will build a simple server with Node.js. Let us add some dependencies we will be needing to our package.json and pull them in:

npm install -S express body-parser pusher

Next, we create a server.js file, where we will build an Express app:

1// ./server.js
2/*
3 * Initialise Express
4 */
5const express = require('express');
6const path = require('path');
7const app = express();
8const bodyParser = require('body-parser');
9app.use(bodyParser.json());
10app.use(bodyParser.urlencoded({ extended: true }));
11app.use(express.static(path.join(__dirname)));
12
13/*
14 * Initialise Pusher
15 */
16const Pusher = require('pusher');
17const pusher = new Pusher({
18  appId:'YOUR_PUSHER_APP_ID',
19  key:'YOUR_PUSHER_APP_KEY',
20  secret:'YOUR_PUSHER_SECRET',
21  cluster:'YOUR_CLUSTER'
22});
23
24/*
25 * Define post route for creating new reviews
26 */
27app.post('/review', (req, res) => {
28  pusher.trigger('reviews', 'review_added', {review: req.body});
29  res.status(200).send();
30});
31
32/*
33 * Run app
34 */
35const port = 5000;
36app.listen(port, () => { console.log(`App listening on port ${port}!`)});

First we initialise an express app, then we initialise Pusher with the required credentials. Remember to replace YOUR_PUSHER_APP_ID, YOUR_PUSHER_APP_KEY, YOUR_PUSHER_SECRET and YOUR_CLUSTER with your actual details from the Pusher dashboard.

Next, we define a route for creating reviews: /review . Whenever this endpoint is hit, we utilise Pusher to trigger a review_added event on the reviews channel and broadcast the entire payload as the review.

The trigger method has this syntax: pusher.trigger(channels, event, data, socketId, callback);. You can read more on it here.

We are broadcasting on a public channel as we want the data to be accessible to everyone. Pusher also allows broadcasting on private (prefixed by private-) and presence (prefixed by private-) channels, which require some form of authentication.

Creating an API proxy

To access our API server from the front-end server created by the Vue Webpack scaffolding, we can create a proxy in config/index.js, and run the dev server and the API backend side-by-side. All requests to /api will be proxied to the actual backend:

1// config/index.js
2module.exports = {
3  // ...
4  dev: {
5    // ...
6    proxyTable: {
7        '/api': {
8        target: 'http://localhost:5000', // you should change this, depending on the port your server is running on
9        changeOrigin: true,
10        pathRewrite: {
11          '^/api': ''
12        }
13      }
14    },
15    // ...
16  }
17}

Then, we adjust our addReview method to post to the API in ./src/components/Reviews.vue:

1<!-- ./src/components/Review.vue -->
2<script>
3// ...
4export default {
5  // ...
6  methods: {
7    addReview () {
8      if (!this.movie || !this.review.reviewer || !this.review.content) {
9        alert('please make sure all fields are not empty')
10        return
11      }
12      let review = {
13        movie_id: this.movie, content: this.review.content, reviewer: this.review.reviewer, time: new Date().toLocaleDateString()
14      }
15      fetch('/api/review', {
16        method: 'post',
17        body: JSON.stringify(review)
18      }).then(() => {
19        this.review.content = this.review.reviewer = ''
20      })
21    }
22    // ...
23  },
24  // ...
25}
26</script>

Listening for events

Finally, in our view, we can listen for events broadcast by Pusher, and update it with details, whenever a new review is published. First we add the pusher-js library:

npm install -S pusher-js

Updating Review.vue:

1<!-- ./src/components/Review.vue -->
2<script>
3import Pusher from 'pusher-js' // import Pusher
4
5export default {
6  // ...
7  created () {
8    // ...
9    this.subscribe()
10  },
11  methods: {
12    // ...
13    subscribe () {
14      let pusher = new Pusher('YOUR_PUSHER_APP_KEY', { cluster: 'YOUR_CLUSTER' })
15      pusher.subscribe('reviews')
16      pusher.bind('review_added', data => {
17        this.mockReviews.unshift(data.review)
18      })
19    }
20  },
21  // ...
22}
23</script>

In the above code, first we import the Pusher object from the pusher-js library, then we create a subscribe method that does the following:

  • Subscribes to the reviews channel with pusher.subscribe('reviews')
  • Listens for the review_added event, with pusher.bind, which receives a callback function as its second argument. Whenever it receives a broadcast, it triggers the callback function with the data broadcast as the function parameter. We update the view in this callback function by adding the new object to the mockReviews array.

Bringing it all together

We can add node server.js to our app’s dev/start script so the API server starts along with the server provided by the webpack template:

1{
2  // ...
3  "scripts": {
4    "dev": "node server.js & node build/dev-server.js",
5    "start": "node server.js & node build/dev-server.js",
6    // ...
7  }
8}

To compile and run the complete app:

npm run dev

Visit localhost:8080 to view the app in action!

Conclusion

In this tutorial, we have learned how to build a Vue.js app with the webpack template. We also learned how to work with Single File Components and common Vue template directives. Finally, we learned how to make our Vue.js app realtime, utilising the simplicity and power of Pusher.

In my opinion, Vue.js is a really robust, and yet simple framework. It provides a great base for building robust realtime applications. There is also another great example here of using Vue.js and Pusher for realtime applications.