Cyrus Stoller home about consulting

Building a basic flashcard game with Vue.js and Vuex

In this tutorial I’m going to show you how to create a simple statically served flashcard game using Vue.js and Vuex. One of the advantages of this approach is that once the page has been rendered, users can test themselves without internet connectivity (e.g. on the subway or on a plane). While it’s certainly possible to build this kind of application with fewer dependencies, this application is meant to demonstrate how to use Vue.js and Vuex to minimize the amount of code that needs to be written to achieve this functionality. When we’re done, you’ll have a flashcard game that will randomly select flashcards from a JSON file, only showing you questions that you have not seen before or ones that you previously answered incorrectly.

Here’s a demo to help you learn morse code based on what you’ll have built at the end of this tutorial, and a link to the github repo.

Getting started

I recommend installing node using nvm in case you decide to build applications that depend on different versions of node. This tutorial was built using node LTS carbon v8.9.4. Once node is installed, you’ll need to install the vuejs cli tools using yarn.

$ yarn global add @vue/cli

Then to create the application scaffold:

$ vue create APPLICATION-NAME
# choose the default template and yarn as the package manager
$ cd APPLICATION-NAME
$ yarn serve

At this point you should see the Hello World vue.js application running in your browser. One of the benefits of this development workflow is that your browser will typically reload on its own to pick up changes made to your vue components without destroying the current state. But before we jump into modifying the code, let’s put together a JSON file with the questions and answers that users will be tested on. The file should look something like:

// src/data/questions.json
[
  {
    "question": "This is question 1",
    "answer": "This is answer 1"
  },
  {
    "question": "This is question 2",
    "answer": "This is answer 2"
  },
  {
    "question": "This is question 3",
    "answer": "This is answer 3"
  }
]

It’s fine if you only add a few questions to this file at this point, but be sure to add at least three, so you can test whether questions that were answered incorrectly are being recycled in the way that you expect. Now, we’re ready to start building our interface. Start by deleting src/components/HelloWorld.vue and src/assets/logo.png, we’re not going to need those anymore. You’ll notice that if you try opening your application, you’ll see that it has a build error. To fix this, we need to change our src/App.vue.

<!-- src/App.vue - template -->
<template>
  <div id="app">
    <div class="score">Score: {{ score }}</div>
    <flashcard :front="question" :back="answer"></flashcard>
  </div>
</template>
// src/App.vue - script
import flashcard from './components/Flashcard.vue'
export default {
  components: {
    flashcard,
  },
  data() {
    return {
      question: 'Sample question', // This will be removed later
      answer: 'Sample answer', // This will be removed later
      score: 0,
    }
  },
}

And then, to render one flashcard, we need to create the skeleton of a Flashcard component.

<!-- src/components/Flashcard.vue - template -->
<template>
  <div class="flashcard">
    Front: {{ front }}
    <br>
    Back: {{ back }}
  </div>
</template>
// src/components/Flashcard.vue - script
export default {
  props: {
    front: {
      type: String,
      default: 'default front',
    },
    back: {
      type: String,
      default: 'default back',
    }
  }
}

Now your application should show ‘Sample Question’ (instead of ‘default front’) and ‘Sample Answer’ (instead of ‘default back’), demonstrating that the data is being passed effectively from the App.vue component to the Flashcard.vue component. To start showing your questions in the application, we’ll use Vuex.

$ yarn add vuex

State management

To register Vuex with our application, we need to add the following to main.js and create store/index.js.

// src/main.js
import Vue from 'vue'
import Vuex from 'vuex'

import App from './App.vue'
import store from './store' // We still need to create this file

Vue.config.productionTip = false
Vue.use(Vuex)

new Vue({
  render: h => h(App),
  store, // this still hasn't been created
}).$mount('#app')

Then in store/index.js we’ll instruct Vuex on the state we’d like to keep track of. I like putting this into a separate directory, so that I can put different types of data into separate files that can later be combined. This makes the code easier to read and debug later.

// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    // The state that we want to track in this application
    unansweredQuestions: [], // Pool of questions to be shown to the user
    answeredQuestions: [], // Questions that have been correctly answered
    currentQuestion: {
      question: 'Sample question',
      answer: 'Sample answer'
    }, // Will be overwritten immediately
    cardFlipped: false, // Whether to show the question or answer
  },
  getters: {
    currentQuestion (state) {
      return state.currentQuestion.question
    },
    currentAnswer (state) {
      return state.currentQuestion.answer
    },
  },
  mutations: {
    // The changes to the state that we'll be making
    setUnanswered (state, questions) {
      // To intitially load the questions from the JSON file you made earlier
      state.unansweredQuestions = questions
    },
    pushUnanswered (state, question) {
      // When a question was answered incorrectly
      state.unansweredQuestions.push(question)
    },
    pushAnswered (state, question) {
      // When a question was answered correctly
      state.unansweredQuestions =
        state.unansweredQuestions.filter((q) => q !== question)
      state.answeredQuestions.push(question)
    },
    setCurrentQuestion (state, question) {
      // Setting the question to be rendered
      state.currentQuestion = question
      state.cardFlipped = false
    },
    flipCard (state) {
      state.cardFlipped = !state.cardFlipped
    },
  },
  actions: {
    init (context) {
      context.commit('setCurrentQuestion', randomQuestion(context))
    },
    correctAnswer (context) {
      const question = context.state.currentQuestion
      context.commit('pushAnswered', question)
      context.commit('setCurrentQuestion', randomQuestion(context))
    },
    wrongAnswer (context) {
      const question = context.state.currentQuestion
      context.commit('pushUnanswered', question)
      context.commit('setCurrentQuestion', randomQuestion(context))
    },
  },
})

function randomQuestion (context) {
  const numQuestions = context.state.unansweredQuestions.length

  if (numQuestions > 0) {
    const randomIndex = Math.floor(numQuestions * Math.random())
    return context.state.unansweredQuestions[randomIndex]
  } else {
    return null
  }
}

When first getting acquainted with Vuex, I was confused by the difference between a mutation and an action. To clarify, mutations are the only functions that can modify state. They don’t care about business logic and they’re intended to be synchronous. On the other hand, actions are where any potential business logic should be written and are intended to asynchronous. So you don’t need to rewrite your components if you make changes to your Vuex code, I recommend calling actions from your vue components whenever possible. You can think of actions as functions that can commit multiple mutations asynchronously. This means that actions are where you would want to make external API calls (typically using a library like axios).

While we’ve defined the state that needs to be stored and how that state will change based on user behavior, we still need to load the questions we wrote earlier. For this application, we only neeed to load up these questions once when the application is mounted. In other applications, data may be determined based on the current path of the page a user is on, but for this application, we’ll just be hard coding the question data. To do this, we’ll use the mounted lifecycle event in main.js.

// src/main.js
import Vue from 'vue'
import Vuex from 'vuex'

import App from './App'
import store from './store'
import questions from './data/questions' // Manually loading the questions

Vue.config.productionTip = false
Vue.use(Vuex)

new Vue({
  render: h => h(App),
  store,
  mounted () { // Added functionality
    this.$store.commit('setUnanswered', questions)
    this.$store.dispatch('init')
  },
}).$mount('#app')

At this point, Vuex knows about the questions we want to show on the flashcards, how they should be represented, and the state it needs to keep track of for the game mechanics.

Vue dev tools

The vue dev tools are fantastic. Even though we haven’t connected Vuex to our user interface yet, we can already examine the question and answer data that has been loaded without needing to write any log statements. Additionally, the vue dev tools allow for “time travel” through the various data states that have been committed in Vuex. Going forward, we’ll be able to check that the state of the data stored in Vuex is the same as we intend when implementing the game mechanics.

Game mechanics

In the template below I added buttons for users to indicate whether they answered the question correctly when looking at the answer on the back. If they answer it correctly, then we should increment the score and, if not, then we should just move on to the next question, while placing the current question back into the pool of questions to be drawn from in the future. The business logic to support these game mechanics are pretty simple.

<!-- src/App.vue - template -->
<template>
  <div id="app">
    <div class="score">Score: {{ score }}</div>
    <flashcard :front="question" :back="answer"></flashcard>

    <div v-if="this.$store.state.cardFlipped">
      <button @click="correct">Correct</button>
      <button @click="wrong">Wrong</button>
    </div>
  </div>
</template>

One of the main changes below is that the question and answer are no longer defined in data, instead they are computed properties that will reflect the changes in the state stored in Vuex. Additionally, we’ve added a correct and wrong method to be triggered by the buttons we added.

// src/App.vue - script
import flashcard from './components/FlashCard'

export default {
  components: {
    flashcard,
  },
  data () {
    return {
      score: 0,
    }
  },
  computed: {
    question () {
      return this.$store.getters.currentQuestion // handled by vuex
    },
    answer () {
      return this.$store.getters.currentAnswer // handled by vuex
    },
  },
  methods: {
    correct () {
      this.$store.dispatch('correctAnswer') // handled by vuex
      this.score++
    },
    wrong () {
      this.$store.dispatch('wrongAnswer') // handled by vuex
    },
  },
}

Explaining the flashcard component

The placeholder flashcard component we wrote earlier isn’t particularly effective since it shows both the question and answer to the user at the same time. Below we instruct the flashcard component to only show either the front or the back.

<!-- src/components/Flashcard.vue - template -->
<template>
  <div class="flashcard" @click="flipCard">
    <div v-show="!isToggle">
      <div class="card-content center">
        <p>{{ front }}</p>
      </div>
    </div>
    <div v-show="isToggle">
      <div class="card-content center">
        <p>{{ back }}</p>
      </div>
    </div>
  </div>
</template>
// src/components/Flashcard.vue - script
export default {
  props: {
    front: {
      type: String,
      default: 'default front',
    },
    back: {
      type: String,
      default: 'default back',
    },
  },
  computed: {
    isToggle () {
      return this.$store.state.cardFlipped // handled by vuex
    },
  },
  methods: {
    flipCard () {
      this.$store.commit('flipCard') // handled by vuex
    },
  },
}
/* src/components/Flashcard.vue - style */
.flashcard {
  border: 1px black;
  border-style: solid;
  padding: 10px;
  margin: 10px;
}

At this point we have a minimalist version of our flashcard game working. I’ll let you figure out how you would like to style it, so that it’s more aesthetically pleasing. As a flourish, I’d recommend adding an animation to show the flashcards flipping like this.

Conclusion

Hopefully you found this helpful. Vue.js and Vuex are powerful tools for building interactive and responsive user interfaces quickly and sustainably. Happy hacking.

Category Tutorial