Blog

How to build a quiz app with Vue

Will Fry6 min read

This post builds a quiz app with Vue 3 and TypeScript. You pick a category and difficulty, answer some multiple choice questions and get a score at the end, with questions from The Trivia API.

The finished code is the Vue starter, and there's a live demo if you want to play it first.

There's also a React version of this post. The two starters share their API code, styles and tests, and behave identically, so this one skips over the shared bits quickly and spends its time on the parts that are actually Vue.

Setting up

Vite's Vue + TypeScript template is a good starting point. Then add the SDK, which gives you typed functions for The Trivia API:

npm create vite@latest vue-quiz -- --template vue-ts
cd vue-quiz
npm install @trivia-api/sdk

Fetching questions

The code that talks to the API is plain TypeScript in src/api.ts, with nothing Vue-specific in it:

export const fetchQuestions = async (settings: QuizSettings) => {
  const questions = await getRandomQuestions({
    limit: settings.amount,
    categories: settings.category === "any" ? undefined : settings.category,
    difficulties: settings.difficulty === "any" ? undefined : settings.difficulty,
  });
 
  return questions.filter(isMultipleChoice);
};

isMultipleChoice narrows the questions to text_choice ones, so TypeScript knows every question has a string correctAnswer and incorrectAnswers. There's also a small describeError that turns a 429 into a "too many requests" message. The React post goes through both in more detail.

Keeping this out of the components is probably the most reusable thing in the whole project. You could lift api.ts into a Svelte app tomorrow and it'd just work.

The game state

The quiz is always in exactly one state, so it's modelled as a discriminated union in App.vue:

type GameState =
  | { status: "setup" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "playing"; rounds: Round[]; current: number }
  | { status: "finished"; rounds: Round[] };
 
const settings = ref<QuizSettings>({ category: "any", difficulty: "any", amount: 10 });
const game = ref<GameState>({ status: "setup" });

Each Round is a question, its answers in the order they're shown (shuffled once, when the questions arrive) and the answer the player chose.

This is where Vue and React start to differ. Answering a question in the React starter means building a new rounds array. In Vue you can just change the round, and reactivity picks it up:

const answer = (choice: string) => {
  if (game.value.status !== "playing") return;
  game.value.rounds[game.value.current].chosen = choice;
};
 
const goToNextQuestion = () => {
  if (game.value.status !== "playing") return;
  if (game.value.current + 1 < game.value.rounds.length) {
    game.value.current++;
  } else {
    game.value = { status: "finished", rounds: game.value.rounds };
  }
};

The status checks at the top aren't just defensive. They narrow the type, so TypeScript knows rounds and current exist on the lines after.

Rendering each state

The template is one component per state, chained with v-if and v-else-if:

<SetupForm
  v-if="game.status === 'setup'"
  :default-settings="settings"
  @start="startQuiz"
/>
 
<StatusCard v-else-if="game.status === 'loading'" title="Loading questions…" is-loading />
 
<QuestionCard
  v-else-if="game.status === 'playing'"
  :key="game.rounds[game.current].question.id"
  :round="game.rounds[game.current]"
  :number="game.current + 1"
  :total="game.rounds.length"
  :score="game.rounds.filter(isCorrect).length"
  @answer="answer"
  @next="goToNextQuestion"
/>

vue-tsc narrows inside v-if the same way TypeScript does inside an if, so game.rounds type-checks in the QuestionCard branch. It's a nice payoff for the union.

The :key matters too. It gives each question a new QuestionCard instance, rather than reusing one and swapping its props, which the focus handling below relies on.

The settings form

The form keeps its own copy of the settings with reactive, so changing a dropdown doesn't touch the real settings until the quiz starts:

const props = defineProps<{ defaultSettings: QuizSettings }>();
const emit = defineEmits<{ start: [settings: QuizSettings] }>();
 
// A copy, so changing a select doesn't change the settings until the quiz starts
const form = reactive<QuizSettings>({ ...props.defaultSettings });

Then v-model does the rest. The .number modifier on the question count turns the select's string value back into a number:

<select v-model.number="form.amount" class="select">
  <option v-for="amount in AMOUNTS" :key="amount" :value="amount">{{ amount }}</option>
</select>

Submitting emits a copy of the form, @submit.prevent="emit('start', { ...form })", and App.vue takes it from there. Copying it means the settings that started the quiz can't change underneath it.

Showing a question

The question card's props and emits are typed with defineProps and defineEmits, which vue-tsc checks wherever the component is used:

const props = defineProps<{
  round: Round;
  /** Which question this is, starting from 1 */
  number: number;
  total: number;
  score: number;
}>();
const emit = defineEmits<{ answer: [answer: string]; next: [] }>();

The answers are buttons. Once the player has answered, they use aria-disabled rather than disabled, because a disabled button can't have focus and keyboard users would get thrown out of the list. The result is shown as text ("Correct answer" and "Your answer") as well as green and red.

Moving focus

When a question appears, the button the player just pressed has gone. So the card moves focus to the question, and then to the "Next question" button once they've answered:

const heading = useTemplateRef("heading");
const nextButton = useTemplateRef("nextButton");
 
onMounted(() => heading.value?.focus());
 
watch(hasAnswered, async (answered) => {
  if (!answered) return;
  // Wait for the button to become visible before focusing it
  await nextTick();
  nextButton.value?.focus();
});

The nextTick is easy to leave out, and nothing tells you it's missing. The "Next" button is hidden with visibility: hidden until you answer (so it keeps its space and the page doesn't jump), and you can't focus a hidden element. The watcher runs before Vue has updated the DOM, so without nextTick the button is still hidden and the focus call silently does nothing. Focus just stays on the answer you picked.

useTemplateRef is new in Vue 3.5. If you're on an older version, a ref(null) with the same name as the template's ref attribute does the same job.

Testing

The tests use Testing Library and mock the SDK, so they never hit the real API:

vi.mock("@trivia-api/sdk", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@trivia-api/sdk")>()),
  getRandomQuestions: vi.fn(),
}));

From there it's render(App) and clicking through like a player would. The tests are the same ones the React starter runs, apart from how the app gets rendered. Seeing the same tests pass against two different frameworks is a pretty good sign the behaviour really is the same.

Where to go from here

The full code is on GitHub. Fork it and do whatever you like with it. If you're after ideas:

  • Themed quizzes, by filtering on tags (e.g. tags: "dinosaurs")
  • Questions where players type the answer, using text_input questions and validateQuestionAnswer to check it
  • Sessions, so players never get the same question twice

The last two need an API key. The docs have everything else.

Build with The Trivia API

The docs cover every endpoint, parameter and response. No API key is needed to get started.

License

Information about the Creative Commons license for The Trivia API.

The API and all data returned from it are licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.

Creative Commons License

This means that the API and the questions it returns are free for noncommercial use. If you have somewhere suitable in your app, then please credit the API somewhere discreet — we love having our hard work shared!

If you would like to use the API commercially, or would like enhanced functionality, this service is offered through a paid subscription.

Sign in

Sign in to access your API key and manage your account.

or continue with email