Building a Trivia App with React and Open Trivia Database API
Tags: react, webdev, javascript, api
Published: 2024-02-04 11:38:42

### 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). 

1. In a terminal, type the following command:
    ``` bash
    npm create vite@latest
    ``` 
2. Select an appropriate name for the folder. (e.g. Trivia).
3. Select React as the framework.
4. Select Javascript as the variant.
5. Navigate into the project directory using the following command:
    ``` bash
    cd Trivia
    ```
6. Install dependencies using the command:
    ``` bash
    npm install
    ```
7. Install React Bootstrap using the following command:
    ``` bash
    npm install react-bootstrap bootstrap
    ```
8. Install Axios using the following command:
    ``` bash
    npm install axios
    ```
9. Install React Query using the following command:
    ``` bash
     npm install react-query
    ```
At the end of the steps above, your `package.json` file should look similar to the image below.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9amcj1fr3u8ultpe7ey9.png)

## 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.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/10zmftnrs0cav4tnjqb9.png)

## 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:
``` javascript
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`: The `optionsReducer` function is responsible for managing the options for each question. 
- `scoreReducer`: The `scoreReducer` 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.
``` javascript
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: 
1. Initialize a new instance of the `QueryClient`. This instance manages the caching and state of queries in your React application.  You should also set the `refetchOnWindowFocus` 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.
2. Wrap the entire application using the `QueryClientProvider`, providing the configured `queryClient` to enable React Query functionality.

At the end of this, your `main.jsx` file should have the following content:
``` javascript
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:
``` javascript
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`: The `label` property represents the descriptive name of each category.
- `value`: The `value` 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:
``` javascript

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:
``` javascript
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:
``` javascript
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:
``` javascript
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:
``` javascript
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. The `retakeQuiz` 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](https://quick-trivia.vercel.app/) and the code on GitHub [here](https://github.com/michaelikoko/Trivia).

## References
- [Open Trivia Database](https://opentdb.com/)
- [React Query](https://tanstack.com/query/v3/)
- [React -createContext](https://react.dev/reference/react/createContext)