Building a Quiz with Vue.js

Building a Quiz with Vue.js

This post is more than 2 years old.

For today's "Can I build that with Vue.js?" blog post, I'm sharing a simple quiz system I've built with Vue.js. The idea was to see if I could write Vue code that would handle a dynamic set of questions, present them to the user one at a time, and then report a grade at the end. I had fun building this, and it went through a few iterations, so let's get started!

Version One

In my initial design, I wanted to support the following:

  • First, the quiz needed to have three different stages. An initial "you are about to take a quiz" view, a "answer this question" view that will cycle through all the questions, and a final "you finished and here is your score" view.
  • For the questions, I decided to just support two types: true/false and multiple choice with one answer. Of course, this could get much more complex. I built a dynamic survey system in ColdFusion a few years back (Soundings), and supporting every type of question took quite a bit of work.
  • Finally, I wanted the actual quiz data to be loaded via JSON, so that any set of questions could be used. (Again though, as long as they matched the criteria defined above.)

Before getting into the code, let's take a look at the JSON structure of the quiz data first.

{
  "title": "Quiz about Foo",
  "questions": [
    {
      "text": "Is true true?",
      "type": "tf",
      "answer": "t"
    },
    {
      "text": "Is false true?",
      "type": "tf",
      "answer": "f"
    },
    {
      "text": "What is the best beer?",
      "type": "mc",
      "answers": [
        "Coors",
        "Miller",
        "Bud",
        "Anchor Steam"
      ],
      "answer": "Anchor Steam"
    },
    {
      "text": "What is the best cookie?",
      "type": "mc",
      "answers": [
        "Chocolate Chip",
        "Sugar",
        "Beer"
      ],
      "answer": "Sugar"
    }
  ]
}

My JSON structure has 2 top level keys, title and questions. The title property simply gives the quiz a name. The questions property is an array of questions. Each question has a text value (the actual question text), a type (either "tf" for true/false or "mc" for multiple choice), and an answer property indicating the right answer. Questions of type mc also have an answers property which is an array of options for the multiple choices.

To host my quiz, I used myjson.com, which is a cool little service that acts like a pastebin for JSON. It also turns on CORS which makes it easy to use the JSON packets in client-side applications.

Ok, so how did I solve this with Vue? First, let's look at the HTML.

<div id="quiz">
  
  <div v-if="introStage">
    <h1>Welcome to the Quiz: {{title}}</h1>
    <p>
      Some kind of text here. Blah blah.
    </p>
    
    <button @click="startQuiz">START!</button>
  </div>
  
  <div v-if="questionStage">
    <question 
              :question="questions[currentQuestion]"
              v-on:answer="handleAnswer"
              :question-number="currentQuestion+1"
    ></question>
  </div>
  
  <div v-if="resultsStage">
    You got {{correct}} right out of {{questions.length}} questions. Your percentage is {{perc}}%.
  </div>
  
</div>

I've got three main parts. Each of the three divs represent one "stage" of the quiz, either before, during, or after. I could have use if/else statements here, but I like the use of a simple if to toggle on each part. The second div is using a question component to render the current question. Now let's look at the code.

First - the main Vue app:

const quizData = 'https://api.myjson.com/bins/ahn1p';

const app = new Vue({
  el:'#quiz',
  data() {
    return {
      introStage:false,
      questionStage:false,
      resultsStage:false,
      title:'',
      questions:[],
      currentQuestion:0,
      answers:[],
      correct:0,
      perc:null
    }
  },
  created() {
    fetch(quizData)
    .then(res => res.json())
    .then(res => {
      this.title = res.title;
      this.questions = res.questions;
      this.introStage = true;
    })

  },
  methods:{
    startQuiz() {
      this.introStage = false;
      this.questionStage = true;
      console.log('test'+JSON.stringify(this.questions[this.currentQuestion]));
    },
    handleAnswer(e) {
      console.log('answer event ftw',e);
      this.answers[this.currentQuestion]=e.answer;
      if((this.currentQuestion+1) === this.questions.length) {
        this.handleResults();
        this.questionStage = false;
        this.resultsStage = true;
      } else {
        this.currentQuestion++;
      }
    },
    handleResults() {
      console.log('handle results');
      this.questions.forEach((a, index) => {
        if(this.answers[index] === a.answer) this.correct++;        
      });
      this.perc = ((this.correct / this.questions.length)*100).toFixed(2);
      console.log(this.correct+' '+this.perc);
    }
  }
})

So from the beginning, my data block handles created flags for each of the three stages as well as storing questions, answer data, and other parts of the quiz. The created block loads the JSON package of my quiz data and then begins the quiz by showing the initial view. Note we use the proper title of the quiz in the first view.

After the user clicks the button to start the quiz, they can begin answering questions. This is where things get a bit complex. Let's look at the question component.

Vue.component('question', {
	template:`
<div>
  <strong>Question {{ questionNumber }}:</strong><br/>
  <strong>{{ question.text }} </strong>

  <div v-if="question.type === 'tf'">
    <input type="radio" name="currentQuestion" id="trueAnswer" v-model="answer" value="t"><label for="trueAnswer">True</label><br/>
    <input type="radio" name="currentQuestion" id="falseAnswer" v-model="answer" value="f"><label for="falseAnswer">False</label><br/>
  </div>

  <div v-if="question.type === 'mc'">
    <div v-for="(mcanswer,index) in question.answers">
    <input type="radio" :id="'answer'+index" name="currentQuestion" v-model="answer" :value="mcanswer"><label :for="'answer'+index">{{mcanswer}}</label><br/>
    </div>
  </div>

  <button @click="submitAnswer">Answer</button>
</div>
`,
  data() {
     return {
       answer:''
     }
  },
	props:['question','question-number'],
	methods:{
		submitAnswer:function() {
			this.$emit('answer', {answer:this.answer});
      this.answer = null;
		}
	}
});

I've got a template that handles rendering the question (both the question number and text) and then uses simple branching to handle the two types of questions. In theory, this is where you could start adding support for additional question types if you wanted. Make note of how the button fires an event handled by the component, but also then "emitted" out to the parent component. The parent component can then store the answer and update the current question number. This is how you advance throughout the quiz. Note that it also detects when you've answered the last question and fires handleResults to - well - handle the results. The code calculates how many questions you got correct, creates a percentage, and then sets the flag to render the final view.

You can take the quiz (and see all the code) below:

See the Pen Vue Quiz (v1) by Raymond Camden (@cfjedimaster) on CodePen.

Round Two

After getting my initial version working, I started to think about some improvements I could make to the code. The first one I thought of was simply moving the quiz itself into a component. This would better abstract out the logic and make it more usable. So one of the coolest parts of this update was how my front end code changed. Now it's this:

<div id="quiz">
  
  <quiz url="https://api.myjson.com/bins/ahn1p"></quiz>
 
</div>

That's freaking cool. Here is the JavaScript. The main changes here is the creation of the quiz component.

Vue.component('quiz', {
  template:`
<div>
  <div v-if="introStage">
    <h1>Welcome to the Quiz: {{title}}</h1>
    <p>
      Some kind of text here. Blah blah.
    </p>
    
    <button @click="startQuiz">START!</button>
  </div>
  
  <div v-if="questionStage">
    <question 
              :question="questions[currentQuestion]"
              v-on:answer="handleAnswer"
              :question-number="currentQuestion+1"
    ></question>
  </div>
  
  <div v-if="resultsStage">
    You got {{correct}} right out of {{questions.length}} questions. Your percentage is {{perc}}%.
  </div>
</div>
`,
  props:['url'],
  data() {
    return {
      introStage:false,
      questionStage:false,
      resultsStage:false,
      title:'',
      questions:[],
      currentQuestion:0,
      answers:[],
      correct:0,
      perc:null
    }
  },
  created() {    
    fetch(this.url)
    .then(res => res.json())
    .then(res => {
      this.title = res.title;
      this.questions = res.questions;
      this.introStage = true;
    })
  
  },
  methods:{
    startQuiz() {
      this.introStage = false;
      this.questionStage = true;
      console.log('test'+JSON.stringify(this.questions[this.currentQuestion]));
    },
    handleAnswer(e) {
      console.log('answer event ftw',e);
      this.answers[this.currentQuestion]=e.answer;
      if((this.currentQuestion+1) === this.questions.length) {
        this.handleResults();
        this.questionStage = false;
        this.resultsStage = true;
      } else {
        this.currentQuestion++;
      }
    },
    handleResults() {
      console.log('handle results');
      this.questions.forEach((a, index) => {
        if(this.answers[index] === a.answer) this.correct++;        
      });
      this.perc = ((this.correct / this.questions.length)*100).toFixed(2);
      console.log(this.correct+' '+this.perc);
    }
  }
  
});

Vue.component('question', {
	template:`
<div>
  <strong>Question {{ questionNumber }}:</strong><br/>
  <strong>{{ question.text }} </strong>

  <div v-if="question.type === 'tf'">
    <input type="radio" name="currentQuestion" id="trueAnswer" v-model="answer" value="t"><label for="trueAnswer">True</label><br/>
    <input type="radio" name="currentQuestion" id="falseAnswer" v-model="answer" value="f"><label for="falseAnswer">False</label><br/>
  </div>

  <div v-if="question.type === 'mc'">
    <div v-for="(mcanswer,index) in question.answers">
    <input type="radio" :id="'answer'+index" name="currentQuestion" v-model="answer" :value="mcanswer"><label :for="'answer'+index">{{mcanswer}}</label><br/>
    </div>
  </div>

  <button @click="submitAnswer">Answer</button>
</div>
`,
  data() {
     return {
       answer:''
     }
  },
	props:['question','question-number'],
	methods:{
		submitAnswer:function() {
			this.$emit('answer', {answer:this.answer});
      this.answer = null;
		}
	}
});

const app = new Vue({
  el:'#quiz',
  data() {
    return {
    }
  }
})

And while it doesn't look any different, you can see the complete app here:

See the Pen Vue Quiz (v2) by Raymond Camden (@cfjedimaster) on CodePen.

Version the Third

For the third version, I decided to add something that I think is really cool. Vue has a feature for components called slots. They allow you to pass markup to a component while actually inside a component. It's a bit complex, but imagine this. You've got a component that allows you to pass in a property for a "thank you" message. Ie, a simple string to use to thank the user. One option would be to pass it to the component:

<mything thankyou="Hey buddy, thank you for doing that thing. I appreciate it. Here's a kitten."></mything>

While that works, if the string gets large, as has markup in it, it can become unwieldy within a property. So Vue allows us to pass in the value inside the component like so:

<mything>

   <div slot="thankyou">
   Hey, I want to <i>really</i> thank you for taking
   the time to do whatever. We here at Mega Corp truly
   care that you took the time. Oh, and here, please
   take a kitten!
   </div>

</mything>

Your Vue component can map the content of that div by using the slot attribute. Your component can even provide it's own default text. It's a pretty cool feature so be sure to read the docs to get it, but how did I use it for my quiz? I used it as a way to let you customize the beginning and end states of the quiz. So check out this version:

<div id="quiz">
  
  <quiz url="https://api.myjson.com/bins/ahn1p">

    <div slot="intro" slot-scope="props">
      This is my custom quiz header for {{props.title}}.
    </div>
  
    <div slot="results" slot-scope="props">
      <h1>WOWOWOW!</h1> 
        You got {{props.correct}} right out of 
        {{props.length}} questions. 
      Your percentage is {{props.perc}}%.
    </div>
  
  </quiz>
 
  
</div>

I've got two slots inside my quiz component now. Note the use of slot-scope. This allows me to access values set in the component itself. A "good" component that is shared with the public will document all of this so developers can easily make use of it. Here is the updated quiz component with this new support added in (I'm just sharing the template portion below):

<div>
  <div v-if="introStage">
    <slot name="intro" :title="title">
    <h1>Welcome to the Quiz: {{title}}</h1>
    <p>
      Some kind of text here. Blah blah.
    </p>    
    </slot>
    <button @click="startQuiz">START!</button>
  </div>
  
  <div v-if="questionStage">
    <question 
              :question="questions[currentQuestion]"
              v-on:answer="handleAnswer"
              :question-number="currentQuestion+1"
    ></question>
  </div>
  
  <div v-if="resultsStage">
    <slot name="results" :length="questions.length" :perc="perc" :correct="correct">
    You got {{correct}} right out of {{questions.length}} questions. Your percentage is {{perc}}%.
    </slot>
  </div>
</div>

Note that I've got text defined for both slots. This will be used as a default so the front end code can choose to customize one or the other, or both, or none. You can find a demo of this, and the complete code, below:

See the Pen Vue Quiz (v3) by Raymond Camden (@cfjedimaster) on CodePen.

So in theory - I could copy question and quiz into a file by itself and anyone could make use of it in their Vue apps. I want to research that a bit more as I assume that I'd probably want to minify it too. Any Vue experts want to chime in on how they would do that? Leave me a comment below!

Raymond Camden's Picture

About Raymond Camden

Raymond is a senior developer evangelist for Adobe. He focuses on document services, JavaScript, and enterprise cat demos. If you like this article, please consider visiting my Amazon Wishlist or donating via PayPal to show your support. You can even buy me a coffee!

Lafayette, LA https://www.raymondcamden.com

Archived Comments

Comment 1 by Anthony posted on 2/28/2018 at 10:05 AM

Interesting... How would you augment this to include simple question constraints for instance: only show the 'maiden name' question if previously you answered 'female' and 'married'? Cheers

Comment 2 (In reply to #1) by Raymond Camden posted on 2/28/2018 at 1:37 PM

Many years ago I worked on a ColdFusion survey project called Soundings (https://github.com/cfjedima.... If I remember right, the way I handled this was by letting a question have a property that determined if it was shown or not. The property would point to another question (an earlier one of course) and a value. So question 10, for ex, would have a property ("toShow" or some such) that pointed to question 9 and the value true.

I handled an ELSE condition by simply ensuring the question after had flipped logic. So question 11 would only show if question 9 was false.

Comment 3 (In reply to #2) by Anthony posted on 3/6/2018 at 12:29 PM

OK this is how I'm currently doing it : https://codesandbox.io/s/ry...

The quiz config lives in /modules/quiz/api/config.js

I'm currently working on intermediary async checks (like verifying the non-existence of the email via a REST call) but I'm not sure how to go about it. I'd be interested in your take on this.

Comment 4 (In reply to #3) by Raymond Camden posted on 3/6/2018 at 1:45 PM

You mean async processing of a quiz answer? While I'm sure it could be done - that to me doesn't really fit in with the idea of a "quiz" though.

Comment 5 by Sebastian Perez posted on 3/20/2018 at 1:09 AM

Raymond , maybe you need to change the multiple choice option to checkbox ... beacuse it's meant to be like that.

Comment 6 (In reply to #5) by Raymond Camden posted on 3/20/2018 at 11:31 AM

I can see both being options. Someone could edit the code to let you use either.

Comment 7 by MP posted on 9/26/2018 at 1:46 PM

sourcecode? I stumble somewhere between stage 2 and 3 with the templates

Comment 8 (In reply to #7) by Raymond Camden posted on 9/29/2018 at 1:42 PM

The full source code is in the CodePen embeds. You can click em to leave my site and hit CodePen directly which will make it easier to get the code.

Comment 9 by Marc de Ruyter posted on 12/11/2018 at 8:28 PM

Hi Raymond,
I can see that this is some neat and elegant code.
What i fail to see: how and where is vue.js loaded?

I have done a few dozen Vue.js tutorial apps, mostly with the CLI and understand that this: "const app = new Vue({ el:'#quiz'," is supposed to 'hook in' into the HTML.
I also re-read all installation docs on vuejs.org and tried a few things, no avail.

Also, your approach to a vue app seems very different: with the html between backticks and all.

Any help to get this running locally appreciated!

Comment 10 (In reply to #9) by Raymond Camden posted on 12/11/2018 at 9:11 PM

So a few answers. When it comes to loading external JavaScript libraries, CodePen lets you specify URLs for your code. So you can add jQuery, or Angular, or Vue, etc. That's why you don't see Vue.js loaded, but you can pretty much pretend like it's a <script> tag you can't see. :)

As for your broader question about the CLI and like - when I first started using Vue, I focused on the "simple script tag" approach instead of the CLI as I found it MUCH easier to grok. No build process. In ways, it's like adding jQuery to a page. One of the reasons I fell in love with Vue is that it supported this option of development.

For me, the CLI is more useful when building a SPA - where the entire thing you're doing is a Vue app. If I'm adding interactivity to an existing site, or simple HTML, I'm going to use the script tag approahc.

As for my HTML in the backticks, note that was only for the components. One of things *much* better about the CLI is that you can use single file components which make the process much cleaner imo.

If I may be so bold - if you go to my Vue tag here (https://www.raymondcamden.c... and start at the bottom, you can see my gradual learning of Vue overtime. You should also check my About page as I've got a few links to external articles I've done on Vue.

And feel free to reach out to me directly with questions.

Comment 11 by Ugo Ibeh posted on 5/3/2019 at 2:38 PM

this is actually awesome. Thanks for sharing

Comment 12 (In reply to #11) by Raymond Camden posted on 5/3/2019 at 2:41 PM

you are welcome :)

Comment 13 by josh posted on 8/26/2019 at 12:55 AM

How did you hide the correct answers?

Comment 14 (In reply to #13) by Raymond Camden posted on 8/26/2019 at 1:51 PM

I don't. :) It would be possible of course. Instead of hitting a URL that includes questions, answers, and solutions, you would need to hit one that doesn't include the right answers. When submitting the quiz, you would hit another URL/API to get the answers. But to be clear, that isn't perfect. A developer can open their tooling and see that request and use it to cheat next time.

You could continue to add layers of security. Force people to login first. Don't return the right answers, just 'check' the answers (ie, you got 1 and 5 right), and so forth.

Comment 15 by Benedict Badilles posted on 10/15/2019 at 6:03 AM

Hi, I'm currently working on a laravel vue project, very similar to this but mine should not let the users go back to previous question. I'm curious about how I can do it because the current_question variable can be edited especially with vue extension for google chrome and can potentially let the users go back to previous question.

Comment 16 (In reply to #15) by Raymond Camden posted on 10/15/2019 at 11:48 AM

Any client side code is vulnerable to people manipulating it. If you wanted to *really* lock this down, the code would be dramatically different. You would use server side code to identify the user and specify a particular question for them. They would get the question data via an API call and it would only ever return the current question.

Comment 17 by Troy May posted on 2/20/2020 at 4:25 PM

Hi Raymond! Thank you for doing half the leg work on helping me get my new quiz in order. I just wanted to let you know of the tiny bugs I saw as I was playing with it. The first is of course, when the quiz is loaded, it flashes the results page for a second then shows the START button. Big booboo. Next, my Dreamweaver app says there were a few coding errors in your js script. I fixed most of them, but not all. If you can somehow help me to code out that issue with the results flashing at the onset, that would be tops, man.

Comment 18 (In reply to #17) by Raymond Camden posted on 2/20/2020 at 7:26 PM

The first thing can be solved easily, by using v-cloak. I added it to the CodePen so it should be there.

You then said there was coding errors. But the app works fine for me. I'm not sure what DW is complaining about so I can't really help.

Comment 19 (In reply to #18) by Troy May posted on 2/21/2020 at 3:05 PM

Thank you for your quick response! Yes, DW is saying your template tilde on line 2 is a "coding error". DW is smoking something though I think. The code from Version 2 works great. I found the results flash to appear only in version3. My other issue, which has nothing to do with your coding, is how I may add a better results page. I must show a summary of all questions, with the correct answers in green and what the user chose(if incorrect) in red. I am trying but been unsuccessful thus far.

Comment 20 (In reply to #19) by Raymond Camden posted on 2/21/2020 at 6:24 PM

You say the results flash only in v3, but v3 is the only one I fixed w/ v-cloak. Are you sure you are still seeing it?

As for the summary changes, it would be possible. You have access to the questions and answers. I mean you can - you would modify

<slot name="results" :length="questions.length" :perc="perc" :correct="correct">

to add additional values for use within the template.

Comment 21 (In reply to #20) by Troy May posted on 2/23/2020 at 8:52 PM

Thanks again for being here, Mr. Camden. The way I engaged was to click the Edit with Codepen link on V3. When the page opened, it would flash the results page before showing the START page. That's why i chose to work with V2. V3 seems to be fixed now.

I appreciate the script. I was not able to get it working the way i wanted though.

Comment 22 by Camry For Sale posted on 4/26/2020 at 8:36 PM

Nothing is loading at all, looks like the JSON hosting app you are using is down?