Quiz App

Build an advanced React and TypeScript quiz app with API-based questions, timer, scoring logic, progress tracking, and results screen

Time to implement the project: ~ 40-60 hours

  • React
  • TypeScript
  • API Fetching
  • Typed State
  • Timer Logic
  • Score Tracking
  • Results Screen
  • Advanced UI Flow

In this advanced React and TypeScript project, you will build an interactive quiz application that fetches questions from an external API, guides users through a timed quiz, tracks answers, calculates the final score, and displays a detailed results screen. The app should feel like a complete product experience rather than a simple question-and-answer component. Users should be able to start a quiz, answer each question, see progress, handle time limits, complete the session, and review their performance at the end.

This project is advanced because it combines API data, strict typing, timed interactions, multi-step UI flow, derived state, result calculation, and error handling. You will need to design reliable TypeScript types for questions, answers, quiz status, timer state, and user selections. The goal is to build an application that remains predictable even when API data is loading, the timer expires, the user skips a question, or the quiz needs to reset and start again.

Project Goal and Advanced Learning Value

The main goal of this project is to teach you how to build a state-heavy React application with TypeScript. A quiz app may look straightforward from the outside, but a strong implementation requires careful control over many connected states: fetched questions, current question index, selected answer, submitted answers, score, timer, loading, error, quiz progress, and final result visibility. Managing these pieces cleanly is what turns the project into advanced practice.

You will learn how to model a complete UI flow instead of rendering one static screen. The app should have clear stages such as setup, loading, active quiz, answer feedback, completed quiz, and results review. Each stage should determine what the user can see and do. This teaches a very important professional skill: building interfaces where state drives the screen, not the other way around.

The TypeScript part makes the project especially valuable. You will practice creating interfaces and types for API responses, normalized quiz questions, answer options, score objects, quiz status values, and component props. This helps prevent bugs before runtime and improves code readability. A well-typed quiz app demonstrates that you can work with dynamic data while still keeping the codebase maintainable and safe.

Recommended Knowledge Before You Start

This is an advanced project, so you should already be confident with React components, hooks, props, state, effects, controlled inputs, conditional rendering, and API requests. You should also understand TypeScript basics well enough to type objects, arrays, function parameters, component props, union types, and API response structures. Without these foundations, the project can quickly become difficult because many pieces of state depend on each other.

You do not need to build a backend for the first version. Questions can come from a public quiz API or from a local mock API-style file. However, the app should still handle real request behavior: loading states, failed requests, empty responses, malformed data, and retry actions. This makes the project much closer to professional frontend work than a hardcoded quiz.

  • Strong understanding of React components, hooks, props, state, effects, and conditional rendering
  • Practical knowledge of TypeScript interfaces, union types, typed arrays, function types, and component props
  • Experience using fetch or another request method to load data from an external API
  • Ability to manage complex state transitions without creating duplicated or conflicting state
  • Understanding of timers, intervals, cleanup functions, and how useEffect manages side effects
  • Comfort with responsive layout, accessible buttons, form controls, and clear interactive feedback

Core Features of the Quiz App

The quiz app should behave like a complete interactive learning or assessment tool. Users should understand where they are in the quiz, how much time remains, which answer they selected, when the quiz is finished, and what their final result means. The implementation should be typed, predictable, and resilient to edge cases instead of relying on fragile assumptions.

Feature Implementation Focus
API-based question loading Fetch questions from an external API or API-like local source. Validate and transform the response into a typed internal format that your components can use safely.
Typed quiz data model Define TypeScript types for questions, answer options, selected answers, quiz status, score summary, and API response objects to reduce runtime mistakes.
Quiz flow management Control the full app flow from start screen to active question, answer selection, next question, completion, results screen, and restart behavior.
Timer functionality Add a countdown timer for the whole quiz or for each question. Handle timer expiration, interval cleanup, and automatic quiz submission when time runs out.
Answer selection and validation Allow users to select one answer per question, prevent invalid submissions, and store each answer in a structured way for final score calculation.
Score tracking Calculate correct answers, incorrect answers, skipped questions, percentage score, and optional performance labels such as beginner, solid, or excellent.
Progress indicator Show current question number, total questions, progress bar, or completed percentage so users always understand how far they are through the quiz.
Results screen Display final score, time usage, answer breakdown, correct answers, selected answers, and a restart option. The result should feel useful, not just decorative.
Loading and error states Show clear feedback when questions are loading, unavailable, or fail to load. Include a retry path so the app does not leave users stuck on a broken screen.

Implementation Guidance for Advanced React and TypeScript Developers

Start by defining the data model before building the interface. Decide what a Question object looks like inside your app, even if the external API uses a different shape. A common professional approach is to fetch the API response, transform it once, and then let the rest of the application work with your own clean internal types. This keeps components simpler and protects the UI from API-specific complexity.

Treat the quiz as a state machine, even if you do not use a formal state machine library. The app has clear statuses: idle, loading, ready, active, completed, and error. Thinking this way prevents many common bugs, such as showing the results screen before answers exist, running the timer before questions load, or allowing users to submit answers after the quiz has already ended.

Be especially careful with timer logic. Intervals must be created and cleaned up correctly, otherwise the app may run multiple timers at once or continue counting after the quiz is finished. Use effect cleanup functions, avoid unnecessary dependencies, and make sure timer expiration triggers a predictable action such as moving to the next question or ending the quiz.

  • Transform external API data into a clean internal TypeScript model before rendering it
  • Use union types for quiz status so the UI can only be in valid application states
  • Keep answer records separate from the current selected option to avoid accidental overwrites
  • Clear intervals properly when the quiz ends, resets, or moves between timed stages
  • Calculate final results from stored answers instead of manually updating score in many places
  • Show disabled states for buttons when the user has not selected an answer yet
  • Handle API loading, empty data, failed requests, and retry behavior as first-class UI states
  • Test the quiz with fast clicks, expired timers, refreshes, missing answers, and restart flows

Common Mistakes When Building a Quiz App

1. Managing quiz flow with many disconnected boolean states

A Quiz App has a clear flow: the user starts on an intro screen, questions load, the quiz becomes active, the user answers questions, the timer may expire, and the final results screen appears. A common mistake is representing this flow with several unrelated boolean values such as isLoading, isStarted, isFinished, hasError, and showResults. At first this feels easy, but the app can quickly enter impossible states.

For example, the app may accidentally be both “loading” and “finished,” or show the result screen while the timer is still running. These bugs happen because the UI flow is not modeled as one controlled state. A quiz is better handled as a small state machine, even if you do not use a formal state-machine library. One status value should describe the current phase of the app.

This is especially important in an advanced React and TypeScript version. TypeScript can help you restrict the app to valid statuses only. When the status is loading, show the loader. When the status is active, show the question screen and timer. When the status is completed, show the results screen. The UI becomes easier to reason about because one state controls what the user can see and do.

Problematic approach:


          const [isLoading, setIsLoading] = useState(false);
          const [isStarted, setIsStarted] = useState(false);
          const [isFinished, setIsFinished] = useState(false);
          const [hasError, setHasError] = useState(false);
          const [showResults, setShowResults] = useState(false);

These booleans can conflict with each other. The component has no single source of truth for the quiz stage.

Better quiz status model:


          type QuizStatus =
            | "idle"
            | "loading"
            | "ready"
            | "active"
            | "completed"
            | "error";

          type QuizState = {
            status: QuizStatus;
            questions: QuizQuestion[];
            currentQuestionIndex: number;
            selectedAnswerId: string | null;
            submittedAnswers: SubmittedAnswer[];
            secondsRemaining: number;
            errorMessage: string | null;
          };

Reducer-driven transitions:


          type QuizAction =
            | { type: "questions/loading" }
            | { type: "questions/loaded"; payload: QuizQuestion[] }
            | { type: "questions/failed"; payload: string }
            | { type: "quiz/started" }
            | { type: "answer/selected"; payload: string }
            | { type: "answer/submitted" }
            | { type: "quiz/completed" }
            | { type: "quiz/restarted" };

          function quizReducer(state: QuizState, action: QuizAction): QuizState {
            switch (action.type) {
              case "questions/loading":
                return {
                  ...state,
                  status: "loading",
                  errorMessage: null
                };

              case "questions/loaded":
                return {
                  ...state,
                  status: "ready",
                  questions: action.payload
                };

              case "questions/failed":
                return {
                  ...state,
                  status: "error",
                  errorMessage: action.payload
                };

              case "quiz/started":
                return {
                  ...state,
                  status: "active",
                  currentQuestionIndex: 0,
                  selectedAnswerId: null
                };

              default:
                return state;
            }
          }

Pay attention to: Do not let the quiz flow depend on many independent booleans. Use one status value and clear transitions so the app can only be in valid stages.

2. Rendering API questions without transforming them into a safe internal format

Quiz APIs often return data in a shape that is not ideal for your UI. The correct answer may be separate from incorrect answers. Answer text may contain encoded HTML entities. Some questions may be missing fields. The order of answers may be predictable unless you shuffle them. A common mistake is passing raw API objects directly into React components and forcing the UI to understand the API format.

A better approach is to transform external data once, right after fetching it. Your app should work with its own internal QuizQuestion type. This internal type should contain a stable question ID, readable question text, answer options, the correct answer ID, and optional points. After that, components do not need to care where the data came from.

This transformation step is where you can decode text, validate required fields, combine correct and incorrect answers, shuffle answer options, and reject malformed questions. It makes the rest of the quiz safer and cleaner.

Problematic approach:


          function Question({ question }) {
            return (
              <section>
                <h2>{question.question}</h2>

                {[question.correct_answer, ...question.incorrect_answers].map((answer) => (
                  <button key={answer}>{answer}</button>
                ))}
              </section>
            );
          }

This component depends on the external API shape. It also uses answer text as the key, which can break if two answer strings are identical.

Better internal model:


          type ApiQuestion = {
            question: string;
            correct_answer: string;
            incorrect_answers: string[];
          };

          type AnswerOption = {
            id: string;
            label: string;
          };

          type QuizQuestion = {
            id: string;
            text: string;
            options: AnswerOption[];
            correctOptionId: string;
            points: number;
          };

Transforming API data:


          function createQuizQuestion(apiQuestion: ApiQuestion, index: number): QuizQuestion {
            const correctOptionId = crypto.randomUUID();

            const correctOption: AnswerOption = {
              id: correctOptionId,
              label: decodeHtml(apiQuestion.correct_answer)
            };

            const incorrectOptions = apiQuestion.incorrect_answers.map((answer) => {
              return {
                id: crypto.randomUUID(),
                label: decodeHtml(answer)
              };
            });

            return {
              id: `question-${index}`,
              text: decodeHtml(apiQuestion.question),
              options: shuffleOptions([correctOption, ...incorrectOptions]),
              correctOptionId,
              points: 10
            };
          }

Clean component rendering:


          function QuestionCard({ question }: { question: QuizQuestion }) {
            return (
              <section className="question-card" aria-labelledby={question.id}>
                <h2 id={question.id}>{question.text}</h2>

                <div className="answer-list">
                  {question.options.map((option) => (
                    <button key={option.id} type="button">
                      {option.label}
                    </button>
                  ))}
                </div>
              </section>
            );
          }

Pay attention to: External API data should not control your component architecture. Normalize, validate, decode, and shuffle questions before rendering them.

3. Updating score directly on every click instead of deriving it from submitted answers

Score tracking is one of the easiest places to create hidden bugs. A beginner implementation often increases the score immediately when the user clicks a correct answer. That works only if the user can click once and never change anything. In a real quiz flow, the user may select an answer, change selection before submitting, skip a question, go back, or accidentally double-click.

A more reliable approach is to store answer records and calculate the score from those records. Each submitted answer should include the question ID, selected option ID, correct option ID, points, and whether the answer was correct. The final score can then be derived at the end. This prevents duplicate scoring and makes the results screen much more useful because you already have all the data needed for review.

Problematic approach:


          function handleAnswerClick(optionId: string) {
            setSelectedAnswerId(optionId);

            if (optionId === currentQuestion.correctOptionId) {
              setScore(score + currentQuestion.points);
            }
          }

This can add points multiple times if the user clicks the correct answer more than once. It also mixes temporary selection with final scoring.

Better submitted answer model:


          type SubmittedAnswer = {
            questionId: string;
            selectedOptionId: string | null;
            correctOptionId: string;
            isCorrect: boolean;
            earnedPoints: number;
            submittedAt: string;
          };

          function createSubmittedAnswer(params: {
            question: QuizQuestion;
            selectedOptionId: string | null;
          }): SubmittedAnswer {
            const isCorrect =
              params.selectedOptionId === params.question.correctOptionId;

            return {
              questionId: params.question.id,
              selectedOptionId: params.selectedOptionId,
              correctOptionId: params.question.correctOptionId,
              isCorrect,
              earnedPoints: isCorrect ? params.question.points : 0,
              submittedAt: new Date().toISOString()
            };
          }

Deriving the final result:


          type QuizResult = {
            totalQuestions: number;
            correctAnswers: number;
            incorrectAnswers: number;
            skippedQuestions: number;
            totalPoints: number;
            maxPoints: number;
            percentage: number;
          };

          function calculateQuizResult(
            questions: QuizQuestion[],
            answers: SubmittedAnswer[]
          ): QuizResult {
            const totalPoints = answers.reduce((sum, answer) => {
              return sum + answer.earnedPoints;
            }, 0);

            const maxPoints = questions.reduce((sum, question) => {
              return sum + question.points;
            }, 0);

            const correctAnswers = answers.filter((answer) => {
              return answer.isCorrect;
            }).length;

            const skippedQuestions = answers.filter((answer) => {
              return answer.selectedOptionId === null;
            }).length;

            return {
              totalQuestions: questions.length,
              correctAnswers,
              incorrectAnswers: questions.length - correctAnswers - skippedQuestions,
              skippedQuestions,
              totalPoints,
              maxPoints,
              percentage: maxPoints === 0 ? 0 : Math.round((totalPoints / maxPoints) * 100)
            };
          }

Pay attention to: Do not manually update score in several places. Store submitted answers and derive score, accuracy, skipped questions, and result details from that source of truth.

4. Creating timer bugs with stale intervals and missing cleanup

Timer logic makes a quiz feel exciting, but it can also create difficult bugs. A common mistake is starting an interval inside useEffect without clearing it correctly. If the quiz restarts, moves between questions, or finishes, multiple intervals may continue running at the same time. The timer may count down twice as fast, continue after the results screen, or submit the quiz more than once.

You should decide whether the timer belongs to the whole quiz or to each question. A full-quiz timer counts down once from the beginning until completion. A per-question timer resets for every question. These are different behaviors, so they should be modeled intentionally. In both cases, the interval must be cleared when the timer is no longer active.

Problematic approach:


          useEffect(() => {
            setInterval(() => {
              setSecondsRemaining(secondsRemaining - 1);
            }, 1000);
          }, [secondsRemaining]);

This creates a new interval every time secondsRemaining changes. It also captures stale state and does not clean up the old interval.

Better timer reducer action:


          type QuizAction =
            | { type: "timer/ticked" }
            | { type: "quiz/completed" };

          function quizReducer(state: QuizState, action: QuizAction): QuizState {
            switch (action.type) {
              case "timer/ticked": {
                if (state.secondsRemaining <= 1) {
                  return {
                    ...state,
                    secondsRemaining: 0,
                    status: "completed"
                  };
                }

                return {
                  ...state,
                  secondsRemaining: state.secondsRemaining - 1
                };
              }

              default:
                return state;
            }
          }

Safe interval setup:


          useEffect(() => {
            if (quizState.status !== "active") {
              return;
            }

            const intervalId = window.setInterval(() => {
              dispatch({ type: "timer/ticked" });
            }, 1000);

            return () => {
              window.clearInterval(intervalId);
            };
          }, [quizState.status]);

Timer display:


          function Timer({ secondsRemaining }: { secondsRemaining: number }) {
            const minutes = Math.floor(secondsRemaining / 60);
            const seconds = secondsRemaining % 60;

            return (
              <p aria-live="polite">
                Time left: {minutes}:{String(seconds).padStart(2, "0")}
              </p>
            );
           }

Pay attention to: Timer effects must be carefully cleaned up. Start the interval only during the active quiz stage, dispatch timer actions, and clear the interval when the quiz ends or resets.

5. Allowing users to move forward before the answer state is valid

A quiz app needs clear answer rules. Can the user skip a question? Can they go back? Can they change an answer after submitting? Can they click “Next” without selecting anything? A common mistake is not deciding these rules, which leads to inconsistent behavior. Sometimes the app accepts empty answers, sometimes it counts them as wrong, and sometimes it crashes because the selected answer is missing.

The UI should make the rules obvious. If an answer is required, disable the next button until an option is selected. If skipping is allowed, provide a separate “Skip” action and record that question as skipped. If answer feedback is shown after selection, lock the options before moving to the next question. These decisions should be reflected in state, not just in button styling.

Problematic approach:


          function NextButton() {
            return (
              <button onClick={goToNextQuestion}>
                Next
              </button>
            );
          }

This allows the user to move forward even if no answer has been selected. The result calculation then has to guess what happened.

Better answer state:


          type QuestionInteractionState = {
            selectedOptionId: string | null;
            isAnswerSubmitted: boolean;
          };

          function canSubmitAnswer(state: QuestionInteractionState): boolean {
            return Boolean(state.selectedOptionId) && !state.isAnswerSubmitted;
          }

          function canGoNext(state: QuestionInteractionState): boolean {
            return state.isAnswerSubmitted;
          }

Controlled option buttons:


          function AnswerOptions({
            question,
            selectedOptionId,
            isAnswerSubmitted,
            onSelect
          }: AnswerOptionsProps) {
            return (
              <div className="answer-options">
                {question.options.map((option) => (
                  <button
                    key={option.id}
                    type="button"
                    disabled={isAnswerSubmitted}
                    aria-pressed={selectedOptionId === option.id}
                    onClick={() => onSelect(option.id)}
                  >
                    {option.label}
                  </button>
                ))}
              </div>
            );
          }

Submit and next actions:


          <button
            type="button"
            onClick={submitAnswer}
            disabled={!canSubmitAnswer(questionState)}
          >
            Submit answer
          </button>

          <button
            type="button"
            onClick={goToNextQuestion}
            disabled={!canGoNext(questionState)}
          >
            Next question
          </button>

Pay attention to: Decide the answer rules before building the UI. Required answers, skipped answers, locked feedback, and navigation should all be handled explicitly.

6. Making the results screen too shallow

Many quiz apps end with a single number: “You scored 70%.” That is not wrong, but it misses a big opportunity. A results screen should help users understand their performance. It can show correct answers, incorrect answers, skipped questions, percentage, total points, high score, and a review of each question. Without this breakdown, the quiz feels more like a toy than a complete learning or assessment tool.

A useful results screen depends on the data structure you created earlier. If you only stored a score number, you cannot show which questions were missed. If you stored submitted answers, you can show the selected answer, the correct answer, and whether the user skipped the question. This is why score should be derived from answer records rather than updated manually.

Problematic result:


          function ResultsScreen({ score }: { score: number }) {
            return (
              <section>
                <h2>Finished!</h2>
                <p>Your score is {score}.</p>
                <button>Restart</button>
              </section>
            );
          }

This gives almost no insight. The user cannot see what went well, what went wrong, or what to review.

Better result summary:


          function ResultsScreen({
            result,
            answers,
            questions,
            onRestart
          }: ResultsScreenProps) {
            return (
              <section className="results-screen">
                <h2>Quiz completed</h2>

                <div className="results-summary">
                  <p>Score: {result.totalPoints} / {result.maxPoints}</p>
                  <p>Accuracy: {result.percentage}%</p>
                  <p>Correct: {result.correctAnswers}</p>
                  <p>Incorrect: {result.incorrectAnswers}</p>
                  <p>Skipped: {result.skippedQuestions}</p>
                </div>

                <QuestionReviewList
                  questions={questions}
                  answers={answers}
                />

                <button type="button" onClick={onRestart}>
                  Restart quiz
                </button>
              </section>
            );
          }

High score persistence:


          function updateHighScore(score: number): number {
            const savedHighScore = Number(localStorage.getItem("quiz-high-score") || 0);
            const nextHighScore = Math.max(savedHighScore, score);

            localStorage.setItem("quiz-high-score", String(nextHighScore));

            return nextHighScore;
          }

Pay attention to: A strong results screen should explain the score. Show answer breakdown, skipped questions, performance percentage, high score, and a clean restart flow.

After completing this project, you will have an advanced React and TypeScript portfolio project that demonstrates API integration, typed state architecture, timer management, score calculation, multi-step UI flow, and resilient error handling. This project is valuable because it reflects the kind of logic-heavy frontend work found in learning platforms, certification tools, onboarding flows, assessments, surveys, and interactive training systems. It shows that you can build not only attractive interfaces, but also reliable applications where data, timing, user input, and results must stay synchronized.

Reference Implementations Worth Studying

TypeScript, Context API, and useReducer reference:
theorib - React Quiz TypeScript App

This is the strongest TypeScript-focused reference for the Quiz App project. It is a React single-page quiz application built with TypeScript and Vite. The app lets users answer React-related multiple-choice questions within a time limit, earn points for correct answers, compare their score with a previous high score, and move through a responsive mobile-first interface.

Pay particular attention to:

  • How TypeScript improves the maintainability of quiz question, answer, and state logic.
  • How React Context API and useReducer can manage global quiz state without adding a heavy external state library.
  • How the project handles timed quiz behavior and point calculation.
  • How the app separates quiz screens from shared state transitions.
  • How a local question source can be structured so the app is still ready for future API-based fetching.

Use this repository as the main TypeScript architecture reference. It is especially useful if you want your version to demonstrate typed state, controlled quiz flow, timer behavior, high score tracking, and maintainable React structure.

API fetching, progress, and high-score reference:
Munyat - React Quiz App

This implementation is useful because it presents the quiz as a full interactive experience rather than a single question component. It includes a start screen, quiz flow, progress tracking, timer, finish screen, score and high-score handling, error state, and useReducer-based state management. It also demonstrates loading questions through a JSON-server style API setup.

When studying the code, focus on:

  • How the app moves between loading, ready, active, finished, and error states.
  • How useReducer keeps question index, answer, points, high score, and timer state predictable.
  • How Progress, Timer, Question, Options, NextButton, StartScreen, FinishScreen, Loader, and Error components divide responsibilities.
  • How API-based question loading changes the project compared with hardcoded local questions.
  • What you would improve in a TypeScript version: stronger API response types, normalized question models, and answer-review data.

Use this repository as the full-flow reference. It is especially helpful for understanding how a quiz app should behave from loading to final score, including timer, progress, error handling, and high score logic.

Fake API and reducer-flow reference:
SaadMahi - React Quiz App

This repository is valuable as another complete quiz-flow implementation. It uses React with a fake API powered by json-server, includes loading and error screens, a start screen, active quiz screen, finish screen, progress display, timer component, point tracking, high score, and reducer-driven state transitions.

While reviewing this project, examine:

  • How json-server can simulate backend question fetching during frontend development.
  • How the reducer stores questions, status, index, answer, points, high score, and remaining seconds.
  • How the app renders different screens based on quiz status instead of manually hiding and showing unrelated sections.
  • How timer and progress components make the quiz feel more game-like and engaging.
  • How the same foundation could be extended with TypeScript, answer review, categories, difficulty levels, and persistent result history.

Use this implementation as the reducer-flow comparison point. It is especially useful if you want to understand how loading, errors, active gameplay, timer countdown, and final results can be coordinated in one React quiz project.

© 2026 ReadyToDev.Pro. All rights reserved.

Methodology

Privacy Policy

Terms & Conditions