Back to Blog
Tutorial

Building a Quiz Component in React with QuizAPI

Build a reusable React quiz component that fetches questions from QuizAPI, manages quiz state, and displays scores. Full TypeScript implementation included.

Bobby Iliev2026-04-0810 min read

What You'll Build

A self-contained React quiz component that fetches questions from QuizAPI, displays them one at a time with a progress bar, and shows the final score. It's fully typed with TypeScript and uses only React hooks for state management.

Prerequisites

  • Node.js 20+
  • React 18+ project (Create React App, Vite, or Next.js)
  • A QuizAPI API key (free at quizapi.io)
  • Basic React and TypeScript knowledge

Setup

If you don't have a React project yet:

npm create vite@latest quiz-app -- --template react-ts
cd quiz-app
npm install

Add your API key to .env:

VITE_QUIZAPI_KEY=your-api-key-here
VITE_QUIZAPI_URL=https://quizapi.io/api/v1

Types

Define the data shapes returned by QuizAPI:

// src/types/quiz.ts
export interface Answer {
  id: string;
  text: string;
  isCorrect: boolean;
}

export interface Question {
  id: string;
  text: string;
  explanation: string | null;
  answers: Answer[];
}

export interface Quiz {
  id: string;
  title: string;
  description: string;
  category: string;
  difficulty: string;
  questionCount: number;
}

API Client

Create a lightweight client for QuizAPI:

// src/lib/api.ts
const API_URL = import.meta.env.VITE_QUIZAPI_URL;
const API_KEY = import.meta.env.VITE_QUIZAPI_KEY;

const headers = { Authorization: `Bearer ${API_KEY}` };

export async function fetchQuizzes(limit = 10): Promise<Quiz[]> {
  const res = await fetch(`${API_URL}/quizzes?limit=${limit}`, { headers });
  const json = await res.json();
  return json.data;
}

export async function fetchQuestions(quizId: string): Promise<Question[]> {
  const res = await fetch(`${API_URL}/questions?quizId=${quizId}`, { headers });
  const json = await res.json();
  return json.data;
}

The Quiz Component

This is the core component. It handles three phases: intro, playing, and results.

// src/components/QuizPlayer.tsx
import { useState, useEffect, useCallback } from "react";
import type { Question, Answer } from "../types/quiz";
import { fetchQuestions } from "../lib/api";

interface QuizPlayerProps {
  quizId: string;
  quizTitle: string;
  onComplete?: (score: number, total: number) => void;
}

export function QuizPlayer({ quizId, quizTitle, onComplete }: QuizPlayerProps) {
  const [questions, setQuestions] = useState<Question[]>([]);
  const [current, setCurrent] = useState(0);
  const [score, setScore] = useState(0);
  const [selected, setSelected] = useState<string | null>(null);
  const [showFeedback, setShowFeedback] = useState(false);
  const [phase, setPhase] = useState<"loading" | "playing" | "results">("loading");

  useEffect(() => {
    fetchQuestions(quizId).then((data) => {
      setQuestions(data);
      setPhase("playing");
    });
  }, [quizId]);

  const question = questions[current];
  const total = questions.length;
  const progress = total > 0 ? ((current + 1) / total) * 100 : 0;

  const handleAnswer = useCallback(
    (answerId: string) => {
      if (showFeedback) return;
      setSelected(answerId);
      setShowFeedback(true);

      const isCorrect = question.answers.find(
        (a) => a.id === answerId
      )?.isCorrect;
      if (isCorrect) setScore((s) => s + 1);

      // Auto-advance after 1.5s
      setTimeout(() => {
        if (current + 1 < total) {
          setCurrent((c) => c + 1);
          setSelected(null);
          setShowFeedback(false);
        } else {
          const finalScore = isCorrect ? score + 1 : score;
          setPhase("results");
          onComplete?.(finalScore, total);
        }
      }, 1500);
    },
    [current, total, score, showFeedback, question, onComplete]
  );

  if (phase === "loading") {
    return <div className="quiz-loading">Loading questions...</div>;
  }

  if (phase === "results") {
    const percentage = Math.round((score / total) * 100);
    return (
      <div className="quiz-results">
        <h2>Quiz Complete!</h2>
        <div className="score-display">
          <span className="score-number">{percentage}%</span>
          <span className="score-detail">
            {score} out of {total} correct
          </span>
        </div>
        <button
          onClick={() => {
            setCurrent(0);
            setScore(0);
            setSelected(null);
            setShowFeedback(false);
            setPhase("playing");
          }}
        >
          Try Again
        </button>
      </div>
    );
  }

  return (
    <div className="quiz-player">
      <div className="quiz-header">
        <h2>{quizTitle}</h2>
        <span>
          Question {current + 1} of {total}
        </span>
      </div>

      {/* Progress bar */}
      <div className="progress-bar">
        <div className="progress-fill" style={{ width: `${progress}%` }} />
      </div>

      {/* Question */}
      <div className="question">
        <h3>{question.text}</h3>
        <div className="answers">
          {question.answers.map((answer) => {
            let className = "answer-btn";
            if (showFeedback && answer.id === selected) {
              className += answer.isCorrect ? " correct" : " incorrect";
            } else if (showFeedback && answer.isCorrect) {
              className += " correct";
            }
            return (
              <button
                key={answer.id}
                className={className}
                onClick={() => handleAnswer(answer.id)}
                disabled={showFeedback}
              >
                {answer.text}
              </button>
            );
          })}
        </div>
      </div>

      {/* Explanation */}
      {showFeedback && question.explanation && (
        <div className="explanation">
          <strong>Explanation:</strong> {question.explanation}
        </div>
      )}
    </div>
  );
}

Using the Component

// src/App.tsx
import { useState, useEffect } from "react";
import { QuizPlayer } from "./components/QuizPlayer";
import { fetchQuizzes } from "./lib/api";
import type { Quiz } from "./types/quiz";

function App() {
  const [quizzes, setQuizzes] = useState<Quiz[]>([]);
  const [activeQuiz, setActiveQuiz] = useState<Quiz | null>(null);

  useEffect(() => {
    fetchQuizzes(10).then(setQuizzes);
  }, []);

  if (activeQuiz) {
    return (
      <div className="app">
        <button onClick={() => setActiveQuiz(null)}>Back to list</button>
        <QuizPlayer
          quizId={activeQuiz.id}
          quizTitle={activeQuiz.title}
          onComplete={(score, total) => {
            console.log(`Scored ${score}/${total}`);
          }}
        />
      </div>
    );
  }

  return (
    <div className="app">
      <h1>Quiz App</h1>
      <div className="quiz-list">
        {quizzes.map((quiz) => (
          <div key={quiz.id} className="quiz-card">
            <h2>{quiz.title}</h2>
            <p>{quiz.description}</p>
            <div className="quiz-meta">
              <span>{quiz.difficulty}</span>
              <span>{quiz.questionCount} questions</span>
            </div>
            <button onClick={() => setActiveQuiz(quiz)}>Play</button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

Styling

Add some basic styles to make it look good:

/* src/styles/quiz.css */
.quiz-player {
  max-width: 640px;
  margin: 0 auto;
  padding: 2rem;
}

.progress-bar {
  height: 4px;
  background: #e5e7eb;
  border-radius: 2px;
  margin: 1rem 0;
}

.progress-fill {
  height: 100%;
  background: #3b82f6;
  border-radius: 2px;
  transition: width 0.3s ease;
}

.answer-btn {
  display: block;
  width: 100%;
  padding: 0.75rem 1rem;
  margin: 0.5rem 0;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  background: white;
  cursor: pointer;
  text-align: left;
  font-size: 1rem;
  transition: border-color 0.2s;
}

.answer-btn:hover:not(:disabled) {
  border-color: #3b82f6;
}

.answer-btn.correct {
  border-color: #10b981;
  background: #ecfdf5;
}

.answer-btn.incorrect {
  border-color: #ef4444;
  background: #fef2f2;
}

.score-number {
  font-size: 3rem;
  font-weight: bold;
  color: #3b82f6;
}

Next Steps

  • Add a timer for each question
  • Save results to localStorage for offline history
  • Add category filtering to the quiz list
  • Implement the QuizAPI leaderboard to show high scores
  • Use the embed endpoint as an alternative to building from scratch

Resources

Stay Updated

Get the latest tutorials and API tips delivered to your inbox.

No spam, unsubscribe anytime.

Enjoyed this article?

Share it with your team or try our quiz platform.