Single Page Apps with Vue.js and Flask: Navigating Vue Router

Welcome to the second post on using Vue.js and Flask for full-stack web development. The major topic in this article will be on Vue Router, but I will also cover the v-model directive, as well as Vue methods and computed properties. That being said, grab something caffeinated and consume some Vue goodness. The code for this post is on my GitHub.

Series Contents

  1. Setup and Getting to Know VueJS
  2. Navigating Vue Router (you are here)
  3. State Management with Vuex
  4. RESTful API with Flask
  5. AJAX Integration with REST API
  6. JWT Authentication
  7. Deployment to a Virtual Private Server

Getting Familiar with Vue Router

Like most other aspects of the Vue.js framework, using Vue Router to navigate the various pages and subsequent components is wickedly easy.

Aside 1 - Stand alone Vue and vue-router Instances

Some of the topics presented in this post will be best described with smaller toy examples, so there will be times when I break out into an aside to do so. In this aside I will demonstrate what is required to drop in a stand alone Vue instance and router. While Vue is absolutely phenomenal for building full fledged SPA applications, there is also real value in the ability to drop it into a regular HTML page.

<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>

<div id="app">
  <div>
    <h3>Cartoon characters</h3>
    <div v-for="(character, i) in characters" v-bind:key="i">
      <h4>{{ character.name }}</h4>
      <p><img v-bind:src="character.imgSrc" v-bind:alt="character.name"/></p>
    </div>
  </div>
</div>

<script>
new Vue({
  el: '#app',
  data: {
    characters: [{
      name: 'Scooby',
      imgSrc: 'https://www.wbkidsgo.com/Portals/4/Images/Content/Characters/Scooby/characterArt-scooby-SD.png'
    }, {
      name: 'Shaggy',
      imgSrc: 'https://upload.wikimedia.org/wikipedia/en/thumb/8/87/ShaggyRogers.png/150px-ShaggyRogers.png'
    } ]
  }
})

</script>

This displays the cartoon characters Scooby and Shaggy. The example introduces the v-bind: directive to dynamically bind data from the characters array to the src and alt attributes of the img element, enabling data to drive the content. This is similar to how text interpolation is done using {{}}, except v-bind interpolates the data into attributes. You can find a working example of this here.

Instead of displaying both characters, let's change our approach, allowing us to click a link for each character to display a specific "Scooby" or "Shaggy" component. To accomplish this I will pull in the vue-router library and make the following changes:

<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>

<div id="app">
  <p>
    <router-link to="/scooby">Scooby</router-link>
    <router-link to="/shaggy">Shaggy</router-link>
  </p>
  <router-view></router-view>
</div>

<script>
const Scooby = {
  template: `
    <div>
      <h4>Scooby</h4>
      <p>
        <img src="https://www.wbkidsgo.com/Portals/4/Images/Content/Characters/Scooby/characterArt-scooby-SD.png" alt="scooby"/>
      </p>
    </div>`
}

const Shaggy = {
  template: `
    <div class="character">
        <h4>Shaggy</h4>
      <p><img src="https://upload.wikimedia.org/wikipedia/en/thumb/8/87/ShaggyRogers.png/150px-ShaggyRogers.png" alt="shaggy"/></p>
    </div>`
}

const router = new vue-router({
  routes: [
    { path: '/scooby', component: Scooby },
    { path: '/shaggy', component: Shaggy }
  ]
})

const app = new Vue({ router: router }).$mount('#app')
</script>

As you can probably tell, this implementation is driven by hard-coded components, which is not a beneficial change from a reusability standpoint. However, it does show an easy-to-follow use of vue-router. Besides sourcing the vue-router library the HTML contains two new elements, components actually, specific to vue-router.

The first vue-router component is <router-link> which is a component that receives a route path via the to attribute, which is actually called a "parameter" in a Vue component. The <router-link> component produces a hyperlink element that responds to click events and tells Vue to display the component associated with its to parameter, either "/scooby" or "/shaggy".

Below <router-link> is another new component, <router-view>, which is where vue-router tells Vue to inject the UI components, Scooby and Shaggy. The Scooby and Shaggy custom template components are defined in the script element at the bottom of this example index.html page.

Next a vue-router object is instantiated with a routes object which defines a routes array similar to what we saw in the first article's Survey app. Here the route paths are mapped to the Scooby and Shaggy components. The last thing to do is instantiate a Vue instance, give it a router object as a property to its options object and bind the instance to the app div.

You can click on either the Scooby or Shaggy router-link to display them, as shown below. The code for this example can be found here.

Using vue-router to Show an Individual Survey

Back in the Survey App, let us begin our discussion by taking a look at the routes/index.js file.

import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home
    }
  ]
})

The Home page displays the application's surveys when the root URL is requested at localhost:8080 because it is mapped to the Home component via the router's routes.

I also need to hook into Vue via the Vue.use(Router) function for a modular SPA application such as this one. Additionally, I need to include the router in the options object, which is fed to the Vue instance in main.js similar to the Scooby/Shaggy toy example.

Continuing with the Survey application I add a new route to the routes object which will map each survey and its questions to a reusable Survey.vue file-based component. In the routes/index.js file import a Survey component and then add a route to map each survey by its id to the Survey component.

// ... omitted for brevity
import Survey from '@/components/Survey'

// ... omitted for brevity

export default new Router({
  routes: [
    {
      // ... omitted for brevity
    }, {
      path: '/surveys/:id',
      name: 'Survey',
      component: Survey
    }
  ]
})

Note the :id portion of the new path /surveys/:id. This is known as a dynamic segment which you can think of as a variable within a route path. In this case I am saying that the :id will be used to identify a specific survey to display in the Survey component to be built next.

In the src/components/ directory create a file called Survey.vue, then open it and add the standard template, script, and style sections along with the code shown below:

<template>
<div>
  <h3>I'm a Survey Component</h3>
</div>
</template>

<script>
export default {
  data() {
    return {
      survey: {}
    }
  },
  beforeMount() {
    console.log('Survey.beforeMount -> :id === ', this.$route.params.id)
  }
}
</script>

<style>

</style>

Saving all files and I start the dev server with npm run dev, then enter the following URL in the browser: localhost:8080/#/surveys/23. In the console of my browser's dev tools I see the image below.

So what just happened?

In the template section I added some nonsense code to make it clear the Survey component is being served by the router. In the script section I initialized a survey object which will hold survey data eventually. In the beforeMount lifecycle hook something pretty cool is happening. In this function I am accessing the current window's route and subsequent :id parameter defined in the route module.

This last part is possible because the Survey component's Vue object has reference to the vue-router instance giving access to the route allowing me to access it with this.$route.params.id. A route can have multiple dynamic segments and all are accessible in their corresponding components via the params member of the this.$route object.

Next I will define a mock AJAX function in api/index.js, which I call from the Survey component's beforeMount hook to fetch a survey by :id. In api/index.js add the following function:

const surveys = [{
  id: 1,
  name: 'Dogs',
  // ... omitted for brevity 
}, {
  id: 2,
  name: 'Cars',
  // ... omitted for brevity 
}]

// ... omitted for brevity

export function fetchSurvey (surveyId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const survey = surveys.find(survey => survey.id === surveyId)
      if (survey) {
        resolve(survey)
      } else {
        reject(Error('Survey does not exist'))
      }
    }, 300)
  })
}

Now back in the Survey component I need to import fetchSurvey and use it in beforeMount to retrieve the requested survey. Again for visual purposes I will output the survey name in the template as a bulma hero header.

<template>
<div>
    <section class="hero is-primary">
      <div class="hero-body">
        <div class="container has-text-centered">
          <h2 class="title">{{ survey.name }}</h2>
        </div>
      </div>
    </section>
</div>
</template>

<script>
import { fetchSurvey } from '@/api'
export default {
  data() {
    return {
      survey: {}
    }
  },
  beforeMount() {
    fetchSurvey(parseInt(this.$route.params.id))
      .then((response) => {
        this.survey = response
      })
  }
}
</script>

<style>
</style>

Updating the browser's URL to localhost:8080/surveys/2, I see what is shown below:

Pretty cool right?

Next I would like to do something a little more useful with my Survey component and include the questions and choices.

<template>
  <div>
    <!-- omitted survey name header for brevity -->

    <section class="section">
      <div class="container">

        <div class="columns">
          <div class="column is-10 is-offset-1">

            <div v-for="question in survey.questions" v-bind:key="question.id">

                  <div class="column is-offset-3 is-6">
                    <h4 class='title has-text-centered'>{{ question.text }}</h4>
                  </div>
                  <div class="column is-offset-4 is-4">
                    <div class="control">
                      <div v-for="choice in question.choices" v-bind:key="choice.id">
                        <label class="radio">
                        <input type="radio" v-model="question.choice" :value="choice.id">
                        {{ choice.text }}
                      </label>
                      </div>
                    </div>
                  </div>

            </div>

          </div>
        </div>

      </div>
    </section>
  </div>
</template>
Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

Again, saving and refreshing the browser with URL localhost:8080/#/surveys/2 now gives a listing of questions and the available choices for the cars survey.

Let me try to unpack some of the new Vue features that are being used. We are already familiar with using the v-for directive to drive the generation of the survey questions and choices, so hopefully you're able to track how those are being displayed. However, if you focus in on how the radio buttons for choices of a question are being generated you will notice I'm doing two new, or slightly different, things.

<div v-for="choice in question.choices" v-bind:key="choice.id">
  <label class="radio">
    <input type="radio" v-model="question.choice" :value="choice.id">
    {{ choice.text }}
  </label>
</div>

For the radio input I have used the v-model directive and supplied it with a value of question.choice. What this does is it creates a new member on the question object called choice and registers it with the radio input allowing data to flow from the actual radio input into the question.choice object property. I am also using a shorthand syntax of :value instead of v-bind:value to bind the value of this radio input to the value of the question choices that are being iterated over via v-for.

Aside 2 - Using the v-model Directive

I realize that the v-model concept is probably a bit fuzzy, so let me step aside and make another simple example to demonstrate the point. Consider the trivial example below. Again, you can see a working example of this code here.

<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>

<div id="app">
  <div>
    <label for="name">What is your name</label>
    <input id="name" type="text" v-model="textInput" />
    <span>Hello {{ textInput }}</span>
  </div>

  <h4>Which do you like better?</h4>
  <div v-for="choice in radioChoices" :key="choice">
    <label>{{ choice }}</label>
    <input name="fruit" type="radio" v-model="favoriteFruit" :value="choice"/>
  </div>
  <h4>So you like {{ favoriteFruit }}</h4>
</div>

<script>
new Vue({
  el: '#app',
  data: {
    textInput: '',
    radioChoices: ['apples', 'oranges'],
    favoriteFruit: ''
  }
})
</script>

The first input is a text input asking for the user's name. This text input has a v-model registered to it with the data property textInput attached to it, which keeps the text input in sync with the textInput data property of the Vue instance. Take a second to type your name into the text input and watch it update in the <span>Hello {{ textInput }}</span> HTML's output.

Amazing right?

The second input is a radio input named "fruit" that displays the fruits "apples" and "oranges" and asks the user to select their favorite. The radio input is registered to the favoriteFruit data property of the Vue instance via the v-model, which associates the value associated with each radio input via the :value="choice" attribute binding syntax to keep favoriteFruit in sync with the selected radio input. Again, you can watch the value of favoriteFruit update in the <h4>So you like {{ favoriteFruit }}</h4> element's output.

Below I show some example output. I encourage you to play around with this example until the notion of v-model is clear.

Completing the Survey Taking Experience

Ok, back to the survey app. Think about the case where a survey has many more questions displayed below the default screen height. Generally, we want to keep people from having to scroll down to see your most important content. A better choice would be to paginate through the questions displaying one question, and its responses, at a time.

The updated Survey component accomplishes this below.

<template>
  <div>
    <!-- omitted for brevity -->
    <section class="section">
      <div class="container">

        <div class="columns">
          <div class="column is-10 is-offset-1">

            <div
              v-for="(question, idx) in survey.questions" <!-- modified v-for -->
              v-bind:key="question.id"
              v-show="currentQuestion === idx"> <!-- new v-show directive -->

                  <div class="column is-offset-3 is-6">
                    <!-- <h4 class='title'>{{ idx }}) {{ question.text }}</h4> -->
                    <h4 class='title has-text-centered'>{{ question.text }}</h4>
                  </div>
                  <div class="column is-offset-4 is-4">
                    <div class="control">
                      <div v-for="choice in question.choices" v-bind:key="choice.id">
                        <label class="radio">
                        <input type="radio" v-model="question.choice" :value="choice.id">
                        {{ choice.text }}
                        </label>
                      </div>
                    </div>
                  </div>

            </div>

            <!-- new pagination buttons -->
            <div class="column is-offset-one-quarter is-half">
              <nav class="pagination is-centered" role="navigation" aria-label="pagination">
                <a class="pagination-previous" @click.stop="goToPreviousQuestion"><i class="fa fa-chevron-left" aria-hidden="true"></i> &nbsp;&nbsp; Back</a>
                <a class="pagination-next" @click.stop="goToNextQuestion">Next &nbsp;&nbsp; <i class="fa fa-chevron-right" aria-hidden="true"></i></a>
              </nav>
            </div>

            <!-- new submit button -->
            <div class="column has-text-centered">
              <a v-if="surveyComplete" class='button is-focused is-primary is-large'
                @click.stop="handleSubmit">
                Submit
              </a>
            </div>

          </div>
        </div>

      </div>
    </section>
  </div>
</template>

<script>
import { fetchSurvey, saveSurveyResponse } from '@/api' // new AJAX func
export default {
  data() {
    return {
      survey: {},
      currentQuestion: 0  // new data prop
    }
  },
  beforeMount() {
    // omitted for brevity
  },
  methods: { // new Vue obj member
    goToNextQuestion() {
      if (this.currentQuestion === this.survey.questions.length - 1) {
        this.currentQuestion = 0
      } else {
        this.currentQuestion++
      }
    },
    goToPreviousQuestion() {
      if (this.currentQuestion === 0) {
        this.currentQuestion = this.survey.questions.lenth - 1
      } else {
        this.currentQuestion--
      }
    },
    handleSubmit() {
      saveSurveyResponse(this.survey)
        .then(() => this.$router.push('/'))
    }
  },
  computed: {  // new Vue obj member
    surveyComplete() {
      if (this.survey.questions) {
        const numQuestions = this.survey.questions.length
        const numCompleted = this.survey.questions.filter(q => q.choice).length
        return numQuestions === numCompleted
      }
      return false
    }
  }
}
</script>

These changes work together to complete the survey-taking experience. As the question nodes are generated from v-for="(question, idx) in survey.questions" I am using the v-show="currentQuestion === idx" directive to test if the value of the data property, currentQuestion, matches the value of idx. This makes the question div visible only if currentQuestion is equal to that question's idx value. Since the value of currectQuestion is initialized to zero, the zeroth question will be displayed by default.

Below the questions and responses the pagination buttons allow the user to paginate through the questions. The "next" button element has @click="goToNextQuestion" within it, which is a Vue click event handler that responds by calling the goToNextQuestion function inside the new methods Vue object property. A Vue component object's methods section is where functions can be defined to do a number of things, most often to change component state. Here goToNextQuestion increments currentQuestion by one, advancing the question being displayed, or it resets it to the first question. The back button and its associated goToPreviousQuestion method does the exact opposite.

The last change is the functionality to submit the survey response. The button uses v-show again to determine if the button should be displayed based on the value of a computed property called surveyCompleted. Computed properties are another awesome trait of Vue. They are properties that usually control how UI components are displayed that come in handy when the logic is a bit more complex than checking a single value of a data property. In this way the template code is clean and able to focus on presentation while the logic remains in the JavaScript code.

A click event listener, @click="handleSubmit", is registered on the submit anchor button, which calls the handleSubmit method. This method calls the mock AJAX function saveSurveyResponse, which returns a promise and passes control to the "then chain". The "then chain" has a callback, .then(() -> this.$router.push('/')), that calls the component's router object and programmatically routes the app back to the root path displaying the Home component.

In the api/index.js, add the saveSurveyResponse function at the bottom of the file. This function receives a survey response object and simply logs "saving survey response..." on the console until I connect the front-end to the REST API in the future.

export function saveSurveyResponse (surveyResponse) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("saving survey response...")
      })
      resolve()
    }, 300)
  })
}

Saving all the files and refreshing the browser with the URL localhost:8080:/#/surveys/2 I see what is below. I recommend clicking around in the application and making sure you can logically trace the control flow through the Survey component.

Aside 3 - Programmatic Routing

Similar to before I want to demonstrate a few things just discussed with a variation of one of the previous toy examples. Below I have altered the navigation example that displays either Scooby or Shaggy to no longer use the <router-link> component.

<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>

<div id="app">
  <p>
    <a @click="toScooby">Scooby</a>
    <a @click="toShaggy">Shaggy</a>
  </p>
  <router-view></router-view>
</div>

<script>
const Scooby = {
    template: `
    <div>
      <h4>Scooby</h4>
      <p>
        <img src="https://www.wbkidsgo.com/Portals/4/Images/Content/Characters/Scooby/characterArt-scooby-SD.png" alt="scooby"/>
      </p>
    </div>`
}

const Shaggy = {
    template: `
    <div class="character">
      <h4>Shaggy</h4>
      <p>
        <img src="https://upload.wikimedia.org/wikipedia/en/thumb/8/87/ShaggyRogers.png/150px-ShaggyRogers.png" alt="shaggy"/>
      </p>
    </div>`
}

const router = new vue-router({
  routes: [
    { path: '/characters/scooby', component: Scooby },
    { path: '/characters/shaggy', component: Shaggy }
  ]
})

const app = new Vue({
  router: router,
  methods: {
    toScooby() { this.$router.push('/characters/scooby') },
    toShaggy() { this.$router.push('/characters/shaggy') }
  }
}).$mount('#app')

</script>

The example behaves in the exact same way as before, but now the routing is done via a combination of click event listeners, Vue methods, and manually calling this.$router.push('/path'). This is actually what <router-link> does behind the scenes using the to="/path" value. I encourage you to play with this live example here.

Adding Router Links to the Home Component

The last thing to do with the Survey component is provide the ability to navigate to a survey from the Home component. As shown previously with the Scooby and Shaggy example, vue-router makes this incredibly easy with <router-link>.

Back in the Home component make the following modification:

<div class="card" v-for="survey in surveys" v-bind:key="survey.id">
  <div class="card-content">
    <p class="title">{{ survey.name}}</p>
    <p class='subtitle'>{{survey.created_at.toDateString()}}</p>
  </div>
  <div class="card-foooter">
    <router-link :to="`surveys/${survey.id}`" class="card-footer-item">Take Survey</router-link>
  </div>
</div>

I added a <router-link> component inside a bulma card footer and dynamically constructed the path to each survey. This is different from the literal string paths I provided in my earlier example. To dynamically produce the paths using the JavaScript template strings and the survey IDs being iterated over I prefix the to parameter with a colon (":"), which is shorthand for the v-bind directive.

I save all files and pull up the root URL path, localhost:8080, in my browser to make sure it works. You should be able to click on each survey's "Take Survey" link and be displayed the Survey component UI.

To complete the experience, I add a simple nav bar with a "Home" tab using <router-link> and a to parameter pointing to the application's root path within a new component file named Header.vue in the component's directory.

<template>
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
  <div class="navbar-menu">
    <div class="navbar-start">
      <router-link to="/" class="navbar-item">
        Home
      </router-link>
    </div>
  </div>
</nav>
</template>

<script>
</script>

<style>
</style>

To ensure that it is included in every page of the application I place it in the App.vue component. To do this I first import the Header component as AppHeader in the script section then register it by adding a property called components on the App component's Vue object and set it equal to an object containing the AppHeader component.

I then add the component in the template placing <app-header> right above the <router-view> component. When naming components it's common to use Pascal case concatenating the words describing it together where each word's first letter is capitalized. Then I include it in the template in all lowercase with hyphens between each word that begins with a capitalized letter.

Saving the files and refreshing the browser I now see the Header component containing the nav bar in each page of the application.

Resources

Want to learn more about Vue.js and building front-end web apps? Try checking out some of the following resources for a deeper dive in to this front-end framework:

Conclusion

If you are still reading this you have consumed quite a bit of Vue goodness. We certainly covered a lot of material in this post and if you are new to Vue or single file component SPAs it is probably worth a second read and a trip over to the excellent Vue docs for a deeper dive.

As always, thanks for reading and don't be shy about commenting or critiquing below.

Last Updated: July 19th, 2023
Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

Adam McQuistanAuthor

I am both passionate and inquisitive about all things software. My background is mostly in Python, Java, and JavaScript in the areas of science but, have also worked on large ecommerce and ERP apps.

Project

React State Management with Redux and Redux-Toolkit

# javascript# React

Coordinating state and keeping components in sync can be tricky. If components rely on the same data but do not communicate with each other when...

David Landup
Uchechukwu Azubuko
Details

Getting Started with AWS in Node.js

Build the foundation you'll need to provision, deploy, and run Node.js applications in the AWS cloud. Learn Lambda, EC2, S3, SQS, and more!

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms