Introduction
This is a beginner-friendly tutorial where you will utilize React and the Open Trivia Database API(Application Programming Interface) to create a trivia application.
This tutorial is valuable to readers at any skill level looking to enhance their proficiency in React and searching for a practical project to apply concepts in React.
In this tutorial, you will implement the following:
- State management using React’s Context-API.
- Client-side routing using React Router.
- Data fetching from an external API using Axios and React-Query.
You will be well acquainted with the concepts listed above at the end of this tutorial.
Prerequisites
- Prior knowledge of JavaScript and React is essential for this tutorial.
- Node.js installed on your computer.
- Access to a web browser for testing and previewing.
- A text editor is necessary for writing and modifying code.
- It is essential to have a basic knowledge of the React Router library.
Initializing the React Project
Initialize a React project using your preferred method. In this tutorial, you will use Vite and Node Package Manager(NPM).
- In a terminal, type the following command:
bash npm create vite@latest
- Select an appropriate name for the folder. (e.g. Trivia).
- Select React as the framework.
- Select Javascript as the variant.
- Navigate into the project directory using the following command:
bash cd Trivia
- Install dependencies using the command:
bash npm install
- Install React Bootstrap using the following command:
bash npm install react-bootstrap bootstrap
- Install Axios using the following command:
bash npm install axios
- Install React Query using the following command:
bash npm install react-query
At the end of the steps above, yourpackage.json
file should look similar to the image below.
Structuring the Project
In this section, you will establish a project layout to enhance code maintainability and organization. You will be creating two sub-directories inside the src folder, namely:
src/pages
src/components
The directory pages
contain views for the different routes in the application. Each file in the pages
directory contains a React component that renders a specific page in the application corresponding to a particular route. These components may include smaller modules from the components
directory, contributing to a modular structure.
The components
directory contains reusable and individual pieces of the user interface. Each file in the directory represents a component that serves as a building block contributing to the overall architecture of the user interface.
Your src
directory should look similar to the image below.
State Management with React’s Context-API
createContext lets you create a context that components can provide or read.
In the project's root directory, create a QuizContext.jsx
file. This file will contain reducer functions, context, and context provider. The QuizContext.jsx
file should have the following content:
import { createContext, useReducer } from 'react'
const optionsReducer = (state, action) => {
switch (action.type) {
case 'SET_OPTIONS':
return action.payload
default:
return state
}
}
const scoreReducer = (state, action) => {
switch(action.type) {
case 'SET_SCORE':
return action.payload
case 'RESET_SCORE':
return null
default:
return state
}
}
const QuizContext = createContext()
export const QuizContextProvider = (props) => {
const [options, optionsDispatch] = useReducer(optionsReducer, [])
const [score, scoreDispatch] = useReducer(scoreReducer, null)
return (
<QuizContext.Provider value={[score, scoreDispatch, options, optionsDispatch]}>
{props.children}
</QuizContext.Provider>
)
}
export default QuizContext
Let’s delve into the contents of the file. Two reducer functions are defined:
-
optionsReducer
: TheoptionsReducer
function is responsible for managing the options for each question. -
scoreReducer
: ThescoreReducer
function is responsible for managing the score. A Reducer function is responsible for managing and updating the state of the application based on the action type. A Reducer function is usually a switch statement. It takes the current state and specified action as parameters, and returns a new state based on the action.
The QuizContext
is created using the createContext
hook.
QuizContext.Provider
is a context provider. It is a React component provided by the created context QuizContext
, that shares the context values to its child components. The value prop of the provider contains an array with four elements: - score
scoreDispatch
options
-
optionsDispatch
. These values represent the state and dispatch functions for the score and options state.{props.children}
renders the children's components that are wrapped by this provider.
Finally, in the main.jsx
file, enclose the App
component with the Context Provider to integrate the context into your application actively.
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import 'bootstrap/dist/css/bootstrap.min.css'
import { QuizContextProvider } from '../QuizContext.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QuizContextProvider>
<App />
</QuizContextProvider>
</React.StrictMode>
)
Setting up React Query
The React Query library is used for managing, caching, and synchronizing data in React applications. It provides a set of hooks that make it easy to fetch, update, and display data while handling various scenarios like caching, background fetching, and optimistic updates.
You will be editing the main.jsx
file to set up React Query. To do this:
- Initialize a new instance of the
QueryClient
. This instance manages the caching and state of queries in your React application. You should also set therefetchOnWindowFocus
option to false to disable automatic fetching on window focus. By default, React Query automatically fetches queries when the user returns to the application window after it has lost focus. - Wrap the entire application using the
QueryClientProvider
, providing the configuredqueryClient
to enable React Query functionality.
At the end of this, your main.jsx
file should have the following content:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import 'bootstrap/dist/css/bootstrap.min.css'
import { QuizContextProvider } from '../QuizContext.jsx'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // default: true
},
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<QuizContextProvider>
<App />
</QuizContextProvider>
</QueryClientProvider>
</React.StrictMode>
)
Making Requests
Create a file named requests.js
in the src
directory. This file provides a module that uses Axios to make requests from the Open Trivia Database. The file requests.js
should have the following content:
import axios from 'axios'
const baseUrl = 'https://opentdb.com/api.php?amount=10&type=multiple'
export const getQuestions = ({ category, difficulty }) => {
return axios
.get(`${baseUrl}&difficulty=${difficulty}&category=${category}`)
.then((res) => res.data)
}
export const categories = [
{ label: 'General Knowledge', value: 9 },
{ label: 'Entertaiment: Music', value: 12 },
{ label: 'Science and Nature', value: 17 },
{ label: 'Sports', value: 21 },
{ label: 'Geography', value: 22 },
]
export const difficulty = ['easy', 'medium', 'hard']
The getQuestions
function is responsible for fetching trivia questions from the Open Trivia Database API. The function takes one parameter which is an object with two properties:
-
category
: It specifies the category of questions. When making requests to the Open Trivia DB each category of questions is represented by a specific number. -
difficulty
: This parameter specifies the difficulty of the question. There are three difficulties available in the Open Trivia Database API, which are: easy, medium, and hard.
The getQuestions
function returns a promise which when resolved returns the data gotten from the Open Trivia Database.
The categories variable is an array of objects. Each object in the array has two properties:
-
label
: Thelabel
property represents the descriptive name of each category. -
value
: Thevalue
property represents the numeric identifier of each category used when making requests.
The difficulty
variable is an array representing the three available difficulty levels.
Both the categories
and difficulty
arrays are exported and will be utilized in the form present on the menu page.
Creating Application Pages
In the App
component, you'll set up the main structure of the React application using React Router.
Edit the file App.jsx
with the following content:
import Navbar from './components/Navbar'
import Menu from './pages/Menu'
import Quiz from './pages/Quiz'
import Result from './pages/Result'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
const App = () => {
return (
<div className="bg-dark text-white" style={{ minHeight: '100vh' }}>
<Navbar />
<Router>
<Routes>
<Route path="/" element={<Menu />} />
<Route path="/quiz/:category/:difficulty/" element={<Quiz />} />
<Route path='/results' element={<Result />} />
</Routes>
</Router>
</div>
)
}
export default App
The code above defines the routes for different pages in the application and displays the corresponding components. The React Router library is responsible for routing, and the main structure of the application is set up in the App
component.
The Navbar
component will consistently appear at the top of the application and will be shared across all the pages in the application. To create the Navbar
component, create the Navabar.jsx
file in the src/component
directory with the following code:
const Navbar = () => {
return (
<nav className="navbar bg-none">
<div className="container">
<a className="navbar-brand text-light fw-bolder fs-3" href="/">
TRIVIA
</a>
</div>
</nav>
)
}
export default Navbar
It is a very simple navigation bar that just contains the name of the application, which when clicked routes to the home page.
The application comprises three pages:
- Menu page.
- Quiz page.
- Result page.
Menu Page
The menu page is the homepage of the application, and its path is specified as: /
. The path is routed to the Menu
component. To create the component, create the file Menu.jsx
in the src/pages
directory with the following content:
import { useState } from 'react'
import { Form } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
import { categories, difficulty } from '../requests'
const Menu = () => {
const navigate = useNavigate()
const [formData, setFormData] = useState({
category: '',
difficulty: '',
})
const selectCategory = (event) => {
setFormData((prev) => ({
...prev,
category: event.target.value,
}))
}
const selectDifficulty = (event) => {
setFormData((prev) => ({
...prev,
difficulty: event.target.value,
}))
}
const startQuiz = (event) => {
event.preventDefault()
//console.log(formData)
navigate(`/quiz/${formData.category}/${formData.difficulty}/`)
}
return (
<div className="container d-flex flex-column align-items-start justify-content-center w-100 py-5 text-white">
<Form
className="my-2 d-flex flex-column align-items-center justify-content-center"
onSubmit={startQuiz}
>
<div>
<div className="h3 my-3">Select Category</div>
{categories.map((cat) => (
<Form.Check
key={cat.value}
type="radio"
id={`${cat.label}`}
label={`${cat.label}`}
name="category"
onChange={selectCategory}
value={cat.value}
/>
))}
</div>
<div>
<div className="h3 my-3">Select Difficulty</div>
{difficulty.map((option) => (
<Form.Check
key={option}
type="radio"
id={`${option}`}
label={`${option}`}
name="difficulty"
className="text-capitalize"
onChange={selectDifficulty}
value={option}
/>
))}
</div>
<button
className={`btn btn-primary w-100 my-3 ${
formData.category === '' || formData.difficulty === ''
? 'disabled'
: ''
}`}
>
Submit
</button>
</Form>
</div>
)
}
export default Menu
The Menu
component contains a form with two sections allowing users to select their preferred quiz category and difficulty level. Both sections utilize radio buttons, enabling users to make a single selection from the provided options. The categories
and difficulty
arrays, exported in src/requests.js
are imported, and mapped to create the options for each section on the form.
The formData
state manages the selected category and difficulty. The state
variable is an object with two properties: category
and difficulty
, each representing the user's choices.
The functions selectCategory
and selectDifficulty
are event handlers that update the state variable formData
when the users select a new category or difficulty.
The startQuiz
function is the event handler clicks on the submission of the form. It navigates to the quiz page with the selected category as a URL parameter.
Quiz Page
The Quiz page displays the questions based on the category and difficulty selected by the user on the Menu page. It is specified by the path: “/quiz/:category/:difficulty/
” and is routed to the Quiz
component. In the src/pages
directory, create the file Quiz.jsx
with the following content:
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { categories, getQuestions } from '../requests'
import { ProgressBar, Spinner } from 'react-bootstrap'
import { useContext, useState } from 'react'
import Question from '../components/Question'
import QuizContext from '../../QuizContext'
const Quiz = () => {
const navigate = useNavigate()
const [isAnswered, setIsAnswered] = useState(false)
const { category, difficulty } = useParams()
const [currentQuestion, setCurrentQuestion] = useState(0)
const [score, scoreDispatch] = useContext(QuizContext)
const nextQuestion = () => {
setCurrentQuestion((prev) => prev + 1)
setIsAnswered(false)
}
const finishQuiz = () => {
if (score === null ){
scoreDispatch({
type: 'SET_SCORE',
payload: 0
})
}
setIsAnswered(false)
navigate('/results')
}
const result = useQuery({
queryKey: ['questions'],
queryFn: () => getQuestions({ category, difficulty }),
})
//console.log(JSON.parse(JSON.stringify(result)))
if (result.isLoading) {
return (
<div className='d-flex flex-column align-items-center justify-content-center my-5'>
<Spinner animation='grow'/>
<div className='my-2 fs-5 lead'>Loading questions ... </div>
</div>
)
}
if (result.error) {
return (
<div className='d-flex flex-column align-items-center justify-content-center my-5'>
<div className='my-2 fs-5 lead'>An Error Occurred</div>
<button
className={`btn btn-primary align-self-end ${
isAnswered === false && 'disabled'
}`}
onClick={nextQuestion}
>
Reload
</button>
</div>
)
}
const questions = result.data.results
return (
<div className="text-white">
<div className="container my-5">
<div className="d-flex flex-column justify-content-start align-items-start">
<div className="d-flex flex-row justify-content-between align-items-center w-100 my-2">
<div className="bg-info p-2 rounded-1">
{categories.find(cat => cat.value === Number(category)).label}
</div>
<div className="bg-warning p-2 rounded-1">
{difficulty}
</div>
</div>
<ProgressBar
animated
now={Math.round(((currentQuestion + 1) / 10) * 100)}
className="w-100"
/>
<span className="my-2">Question {currentQuestion + 1}/10</span>
</div>
<div className="my-4 d-flex flex-column justfy-content-center">
<Question
question={questions[currentQuestion]}
setIsAnswered={setIsAnswered}
isAnswered={isAnswered}
/>
{questions.length === currentQuestion + 1 ? (
<button
className={`btn btn-primary align-self-end ${
isAnswered === false && 'disabled'
}`}
onClick={finishQuiz}
>
Finish
</button>
) : (
<button
className={`btn btn-primary align-self-end ${
isAnswered === false && 'disabled'
}`}
onClick={nextQuestion}
>
Next
</button>
)}
</div>
</div>
</div>
)
}
export default Quiz
The Quiz
component is responsible for rendering the quiz interface, navigating between questions, and manages the user’s response to each question.
In the Quiz
component the category
and difficulty
selected from the form in the Menu page are retrieved from the URL parameters using React router’s useParams()
function. The questions are fetched from the Open Trivia Database API using the useQuery()
hook from the ReactQuery library. The queryFn
property specifies the function responsible for data fetching. The value of the queryFn
property is an anonymous arrow function that invokes the getQuestions
function from src/requests.js
passing the object containing the category
and difficulty
passed as the function parameter. The query result is saved in the variable result
. The result
variable is an object that holds information about the loading state, error state, and the data returned from the query function call.
The property result.isLoading
has a true
value during the query’s progress. During this period, a Spinner animation is displayed, to indicate the loading status.
The property result.error
has a true
value if an error occurs and the query is unsuccessful.
Upon a successful query, the fetched data is extracted from result.data.results
and saved to the questions
variable.
The Question
component is responsible for rendering each quiz question, including the options, and handling user interactions. In the src/components
directory, create the file Question.jsx
with the following content:
import { useContext, useEffect, useState } from 'react'
import QuizContext from '../../QuizContext'
const shuffle = (array) => {
return array.sort(() => Math.random() - 0.5)
}
const Question = ({ question, setIsAnswered, isAnswered }) => {
const [selectedOption, setSelectedOption] = useState('')
const [score, scoreDispatch, options, optionsDispatch] = useContext(QuizContext)
useEffect(() => {
const shuffledOptions = shuffle([
question.correct_answer,
...question.incorrect_answers,
])
optionsDispatch({
type: 'SET_OPTIONS',
payload: shuffledOptions,
})
}, [question])
const selectOption = (opt) => {
setSelectedOption(opt)
if (opt === question.correct_answer) {
scoreDispatch({
type: 'SET_SCORE',
payload: score + 1,
})
}
setIsAnswered(true)
}
const displayedOptions = options
return (
<div>
<div>
<div className="fs-4 fw-bold lead">
<div dangerouslySetInnerHTML={{ __html: question.question }} />
</div>
<div className="list-group my-3">
{isAnswered
? displayedOptions.map((opt, i) => (
<button
key={i}
type="button"
className={`list-group-item list-group-item-action my-1 rounded-pill disabled ${
opt === question.correct_answer && 'bg-success border border-success text-white'
} ${
opt === selectedOption &&
opt !== question.correct_answer &&
'bg-danger border border-danger text-white'
}`}
disabled
onClick={() => selectOption(opt)}
>
<div dangerouslySetInnerHTML={{ __html: opt }} />
</button>
))
: displayedOptions.map((opt, i) => (
<button
key={i}
type="button"
className="list-group-item list-group-item-action my-1 rounded-pill"
onClick={() => selectOption(opt)}
>
<div dangerouslySetInnerHTML={{ __html: opt }} />
</button>
))}
</div>
</div>
</div>
)
}
export default Question
The Question
component receives three props:
-
question
: An object containing the question, the correct option, and the incorrect options. A typical question object looks like this: ```javascript { "type": "multiple", "difficulty": "easy", "category": "Sports", "question": "Which player holds the NHL record of 2,857 points?", "correct_answer": "Wayne Gretzky", "incorrect_answers": [ "Mario Lemieux ", "Sidney Crosby", "Gordie Howe" ]
- `isAnswered`: A state variable that tracks whether each question has been answered. This variable is declared in the `Quiz` component.
- `setIsAnswered`: The function responsible for editing the `isAnswered` variable.
Using the Fisher-Yates algorithm, the `shuffle` function takes an array and shuffles its elements. The function declared in the `useEffect` hook calls the `shuffle` function, and saves the shuffled options to state using the `optionsDispatch` function, each time the `question` prop changes. This ensures the options are randomized each time a new question is rendered.
The `selectOption` function is called when an option is clicked. It updates the state variable `selectedOption` with the option selected. The function checks if the selected option is correct and updates the score appropriately. It sets the `isAnswered` state to true to indicate the question has been answered.
Each option is rendered as a button when the question is unanswered, i.e., `isAnswered` state is `false`.
When the question is answered, i.e., the `isAnswered` state variable is `true`, the correct option is highlighted green. If the incorrect option is selected, it is highlighted in red. All the buttons are disabled to prevent clicking an option twice.
### `Results Page`
The results page displays the results of the quiz taken by the user. It is specified by the path: ‘`/results`’ and is routed to the `Result` component. In the `src/pages` directory, create the file `Result.jsx` with the following content:
``` javascript
import { useContext, useEffect, useState } from 'react'
import QuizContext from '../../QuizContext'
import { useNavigate } from 'react-router-dom'
const Result = () => {
const [highscore, setHighScore] = useState(localStorage.getItem('highScore'))
const [score, scoreDispatch] = useContext(QuizContext)
const navigate = useNavigate()
useEffect(() => {
if (score === null) {
navigate('/')
} else if (score > highscore) {
setHighScore(score)
localStorage.setItem('highScore' ,JSON.stringify(score))
}
}, [])
const goToMainMenu = () => {
scoreDispatch({
type: 'RESET_SCORE',
})
navigate('/')
}
const retakeQuiz = () => {
scoreDispatch({
type: 'RESET_SCORE',
})
navigate(-1)
}
const scorePercentage = Math.round((score / 10) * 100)
const highScorePercentage = highscore === null ? 0 : Math.round((highscore / 10) * 100)
return (
<div className="container d-flex flex-column align-items-center justify-content-center my-5">
<div className="h3">RESULT</div>
<div
className={`${
scorePercentage > 40 ? 'bg-success' : 'bg-danger'
} my-2 w-100 p-3 rounded-pill text-center`}
>
You scored {score} out of {10} ({scorePercentage}%)
</div>
<div className="my-2">Highscore: {highscore || 0} ({highScorePercentage}%)</div>
<div className="d-flex flex-row align-items-center justify-content-center my-3 w-100 ">
<button className="btn btn-info w-25 mx-3" onClick={goToMainMenu}>
Main Menu
</button>
<button className="btn btn-info w-25 mx-3" onClick={retakeQuiz}>
Retake Quiz
</button>
</div>
</div>
)
}
export default Result
The Result
component is responsible for displaying the quiz results updating and displaying the high score.
The highscore
state variable stores the high score. Its initial value when the component is rendered is gotten from a previously saved value in the local storage. If the high score value from the local storage is unavailable, the state variable has a value of null
.
The user’s score from the already concluded test is gotten from the context using the useContext()
hook.
The function in the useEffect
hook in the Results
component runs only once when the page loads as there are no dependencies. The function first checks if there’s a value for the score
state. The score
state will have a value of null
if the quiz wasn’t taken, which happens when the user navigates to the results page directly. If the score
state has a value of null
, the application navigates to the homepage using the useNavigate
hook. The function then checks if the score
value is higher than the highscore
. If the condition is true
, it updates the highscore
state accordingly and saves the high score to local storage.
The Results
component provides two navigation buttons labeled as:
-
Retake Quiz: This button calls the
retakeQuiz
function when clicked. TheretakeQuiz
function resets the user’s score and navigates back to the previous page. This effectively allows the user to retake the quiz with the previously selected category and difficulty. -
Main Menu: This button calls the
goToMainMenu
function when clicked. This function resets the user’s score and navigates to the main menu.
Conclusion
At the end of this article, you've built an interactive quiz app using React. Through state management and React Router, you've created a seamless and engaging experience. The application includes features like category and difficulty selection, real-time feedback, and high-score tracking.
Explore the live version here and the code on GitHub here.
Top comments (0)