How to build a quiz app with React
Will Fry7 min read
A quiz app is a nice size of React project. It's big enough that you have to think about state, and small enough to finish in an evening.
The one in this post lets you pick a category and difficulty, answer some multiple choice questions, then see how you did. The questions come from The Trivia API, so there's no question bank to write first.
The finished app is the React starter, and you can play it here. If you'd rather read the code than a walkthrough of it, clone that and skip this post entirely (no hard feelings).
There's a Vue version too, which shares the same design and tests if you want to compare the two.
Setting up
Start with a Vite project using the React + TypeScript template, then add the SDK:
npm create vite@latest react-quiz -- --template react-ts
cd react-quiz
npm install @trivia-api/sdkThe SDK is a thin, typed wrapper around the API with no dependencies, so TypeScript knows exactly what a question looks like. Plain fetch would work too (the first post on this blog shows how), but I think the types more than pay for themselves in a React app.
Fetching questions
Everything that talks to the API lives in one file, src/api.ts. First the settings a player can choose:
import {
ApiError,
getRandomQuestions,
type Category,
type TextChoiceQuestion,
type V2Question,
} from "@trivia-api/sdk";
export type Difficulty = "easy" | "medium" | "hard";
export type QuizSettings = {
category: Category | "any";
difficulty: Difficulty | "any";
amount: number;
};Then the function that turns those settings into questions:
const isMultipleChoice = (question: V2Question): question is TextChoiceQuestion =>
question.type === "text_choice";
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);
};Two bits of that aren't obvious:
- Leaving a filter out means "any" to the API, so
"any"gets turned intoundefinedrather than sent as a value - The API can return a few types of question, so
getRandomQuestionsis typed as returning any of them. You only get multiple choice ones unless you ask for something else, but the filter tells TypeScript that too, so later code can usecorrectAnswerandincorrectAnswersas plain strings
The last bit of api.ts turns errors into something a player can actually read:
export const describeError = (error: unknown) => {
if (error instanceof ApiError && error.status === 429) {
// Without an API key, each IP can make 20 requests every 5 seconds
return "Too many requests. Wait a few seconds and try again.";
}
return "Couldn't load questions. Check your connection and try again.";
};Shuffling the answers
Each question comes with its correct answer and three incorrect ones as separate fields, so they need mixing together before you show them. The starter uses a Fisher-Yates shuffle:
export const shuffle = <T,>(items: readonly T[]): T[] => {
const shuffled = [...items];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};The answers are shuffled once, when the questions arrive, and stored. Shuffling during render would reorder them every time the component re-renders, which is a fun way to confuse people.
Modelling the game
This is the bit I'd spend the most time on in your own app. The quiz is always in exactly one state (setting up, loading, playing, finished or showing an error), and a discriminated union makes that explicit:
type GameState =
| { status: "setup" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "playing"; rounds: Round[]; current: number }
| { status: "finished"; rounds: Round[] };Each state only has the data it needs, so there's no way to be "playing" without any questions, or to show an error without a message. TypeScript narrows the type when you check status too, so inside a game.status === "playing" check it knows rounds is there.
A Round is a question plus what the player did with it:
export type Round = {
question: TextChoiceQuestion;
answers: string[];
/** The answer the player picked, or null if they haven't answered yet */
chosen: string | null;
};Starting a quiz moves through those states:
const startQuiz = async (newSettings: QuizSettings) => {
setSettings(newSettings);
setGame({ status: "loading" });
try {
const questions = await fetchQuestions(newSettings);
if (questions.length === 0) {
setGame({
status: "error",
message: "No questions matched those settings. Try a different category or difficulty.",
});
return;
}
setGame({
status: "playing",
current: 0,
rounds: questions.map((question) => ({
question,
answers: shuffle([question.correctAnswer, ...question.incorrectAnswers]),
chosen: null,
})),
});
} catch (error) {
setGame({ status: "error", message: describeError(error) });
}
};The fetch happens in the event handler, rather than in a useEffect that watches the settings. One click is one request, and you don't have to think about effects running twice in development with StrictMode. My rule of thumb: if something happens because the player did something, it belongs in the event handler.
Rendering is then basically one block per state:
{game.status === "setup" && <SetupForm defaultSettings={settings} onStart={startQuiz} />}
{game.status === "loading" && <StatusCard title="Loading questions…" isLoading />}
{game.status === "playing" && (
<QuestionCard
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}
onAnswer={answer}
onNext={goToNextQuestion}
/>
)}The key gives each question a fresh QuestionCard, which matters for the focus handling below.
The settings form
React 19 lets a form take a function as its action, which gets the submitted values as FormData. That means the selects don't need any state of their own:
const start = (formData: FormData) =>
onStart({
category: formData.get("category") as Category | "any",
difficulty: formData.get("difficulty") as Difficulty | "any",
amount: Number(formData.get("amount")),
});
return (
<form action={start} className="card">
{/* selects named category, difficulty and amount */}
</form>
);Showing a question
The question card shows the answers as buttons. Once the player picks one, it shows which answer was right, plus a button to move on:
<button
type="button"
className={`answer answer--${state}`}
aria-disabled={hasAnswered}
onClick={() => !hasAnswered && onAnswer(answer)}
>
<span>{answer}</span>
{state === "correct" && <span className="answer__tag">Correct answer</span>}
{state === "incorrect" && <span className="answer__tag">Your answer</span>}
</button>Two details in there are for keyboard and screen reader users:
- The buttons use
aria-disabledinstead ofdisabledafter answering. A disabled button can't have focus, so keyboard users would get thrown out of the list. - "Correct answer" and "Your answer" are text as well as green and red, so the result doesn't rely on telling colours apart.
Focus also needs managing. When a new question appears, the button the player just pressed has gone, so focus moves to the question. After answering, it moves to the "Next question" button:
useEffect(() => headingRef.current?.focus(), []);
useEffect(() => {
if (hasAnswered) nextButtonRef.current?.focus();
}, [hasAnswered]);That's why the key on QuestionCard matters: each question mounts a new card, so the first effect runs once per question. And it means you can play the whole quiz with just Tab and Enter.
Results
The score is derived from the rounds rather than tracked separately:
export const isCorrect = (round: Round) => round.chosen === round.question.correctAnswer;
const score = rounds.filter(isCorrect).length;Keeping a separate score in state is a common way to end up with a score that doesn't match the answers. Deriving it means it can't.
Testing
The tests mock the SDK, so they're fast and never call the real API. Vitest's importOriginal keeps the real ApiError class while replacing the network call:
vi.mock("@trivia-api/sdk", async (importOriginal) => ({
...(await importOriginal<typeof import("@trivia-api/sdk")>()),
getRandomQuestions: vi.fn(),
}));Then a test can play through a game the way a player would:
await user.click(screen.getByRole("button", { name: "Start quiz" }));
expect(await screen.findByRole("heading", { name: "What is H2O?" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Water" }));
expect(screen.getByRole("status")).toHaveTextContent("Correct!");There's a test for the rate limit message too, using mockRejectedValueOnce(new ApiError(429, "", "Too Many Requests")).
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.