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.
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
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.
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.
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
},
},
}
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.
Hopefully you found this helpful. Vue.js and Vuex are powerful tools for building interactive and responsive user interfaces quickly and sustainably. Happy hacking.