DEV Community

Michael Ikoko
Michael Ikoko

Posted on

Building a Trivia App with React and Open Trivia Database API

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:

    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:

    cd Trivia
    
  6. Install dependencies using the command:

    npm install
    
  7. Install React Bootstrap using the following command:

    npm install react-bootstrap bootstrap
    
  8. Install Axios using the following command:

    npm install axios
    
  9. Install React Query using the following command:

     npm install react-query
    

At the end of the steps above, your package.json file should look similar to the image below.

Image description

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

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
Enter fullscreen mode Exit fullscreen mode

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.

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>
)
Enter fullscreen mode Exit fullscreen mode

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:

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>
)
Enter fullscreen mode Exit fullscreen mode

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']

Enter fullscreen mode Exit fullscreen mode

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:


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:
            {
      "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"
      ]

Enter fullscreen mode Exit fullscreen mode
  • 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:

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
Enter fullscreen mode Exit fullscreen mode

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 and the code on GitHub here.

References

Top comments (0)