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/sdkFetching 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_inputquestions andvalidateQuestionAnswerto 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.