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.
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.
Related Articles
How to Build a Quiz App with Django and QuizAPI
Step-by-step guide to building a quiz application with Django using the QuizAPI REST API. Fetch questions, render a quiz UI, and submit scores.
Building a Quiz Leaderboard with Real-Time Updates
Build a live quiz leaderboard with ranking algorithms, efficient data models, and real-time delivery using SSE and WebSockets.
Add Quizzes to Your Laravel App with QuizAPI
Integrate QuizAPI into a Laravel application with the HTTP client, Blade templates, and proper form handling for a complete quiz experience.
Enjoyed this article?
Share it with your team or try our quiz platform.