Building a Dog Quiz with React

#Javascript #React

In this article we will use React and an API that serves up dog pictures to construct a quiz on dog breeds, where players will have to identify each dog's breed based on its picture.

image for Building a Dog Quiz with React

Project Setup

In this article we will build a little quiz on dog breeds with React. The player will have to guess each dog's breed based on its picture.
You can find the starter code in this Github repository.
We will begin on the "start" branch. You can simply install the project with:

npm install

Then run it with:

npm run dev

The App component provides a basic structure for our React application. The Header and Footer are already done and we will not be changing them in the next steps.
Most of our application logic will be put in the Quiz component and new components that will go inside it. We also provide some global CSS in the index.css file what we will not detail here.

src/App.jsx

import Header from './components/Header'
import Quiz from './components/Quiz'
import Footer from './components/Footer'

function App() {
  return (
    <>
      <Header />
      <Quiz />
      <Footer />
    </>
  )
}

export default App

Building the Questions List

We will use the DOG CEO API to provide us with dog pictures.
We will first get the list of all breeds/sub-breeds available from this endpoint "https://dog.ceo/api/breeds/list/all".
From this endpoint, we will get a response that looks like this:

message: {
  affenpinscher: [ ],
  african: [ ],
  airedale: [ ],
  akita: [ ],
  appenzeller: [ ],
  australian: [
    "shepherd"
  ],
  basenji: [ ],
  beagle: [ ],
...

With this list we will build a random array of five breeds/sub-breeds that will be our questions list, i.e. the list of dog breeds we will display to the player.

First we will build a little helper function that will be useful to shuffle the content of an array.

src/utils/shuffleArray.js

const shuffleArray = (array) => {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    const temp = array[i]
    array[i] = array[j]
    array[j] = temp
  }
  return array
}

export default shuffleArray

We will also build a reusable Button component.

src/components/Button.jsx

const Button = ({ onClick, children, disabled }) => {
  return (
    <button type='button' onClick={onClick} disabled={disabled}>
      {children}
    </button>
  )
}

export default Button

Then for the main part of this step we will work in the Quiz component.

We create three state variables:

  • errorMessage: to store an error message in case the API request fails.
  • breeds: to store an array of dog breeds fetched from the API.
  • questions: to store an array of randomized dog breeds that will be used as quiz questions.

We define a QUIZ_LENGTH constant with a value of 5, which represents the number of questions to be generated for the quiz. In the useEffect hook, we fetch the list of dog breeds from the Dog API when the component mounts.
If the response is successful the data is parsed to extract the breed names and sub-breed names (if any), and they are stored in the breeds state array using the setBreeds function.
If the response is not successful, an error message is set in the errorMessage state using the setErrorMessage function.

If there is an error message, the Quiz component renders the error message in the UI using the errorMessage state variable.
Else, the Quiz component returns a main element that contains a custom Button component with an onClick handler set to the buildQuestionsList function. This will allow the user to start the quiz.
For now the buildQuestionsList function will simply shuffle the breeds array, slice it to our quiz length (5) and set this to be the questions value.

src/components/Quiz.jsx

import { useState, useEffect } from 'react'
import shuffleArray from '../utils/shuffleArray'
import Button from './Button'

const Quiz = () => {
  const [errorMessage, setErrorMessage] = useState(null)
  const [breeds, setBreeds] = useState([])
  const [questions, setQuestions] = useState([])

  const QUIZ_LENGTH = 5

  const buidQuestionsList = () => {
    const breedsArray = shuffleArray(breeds).slice(0, QUIZ_LENGTH)
    setQuestions(breedsArray)
    console.log(breedsArray)
  }

  useEffect(() => {
    const fetchBreeds = async () => {
      const res = await fetch('https://dog.ceo/api/breeds/list/all')
      if (res.ok) {
        const data = await res.json()
        const tempBreeds = []
        for (let breed in data.message) {
          if (data.message[breed].length > 0) {
            tempBreeds.push(
              ...data.message[breed].map((subBreed) => `${breed}/${subBreed}`)
            )
          } else {
            tempBreeds.push(breed)
          }
        }
        setBreeds(tempBreeds)
      } else {
        setErrorMessage('Something went wrong!')
      }
    }
    fetchBreeds()
  }, [])

  if (errorMessage) {
    return (
      <main>
        <div className='error'>{errorMessage}</div>
      </main>
    )
  }

  return (
    <main>
      <Button onClick={buidQuestionsList}>Start Quiz</Button>
    </main>
  )
}

export default Quiz

For now, we will simply console.log our questions list. We get something like this:

(5) ['groenendael', 'hound/english', 'cockapoo', 'terrier/russell', 'hound/blood']
0: "groenendael"
1: "hound/english"
2: "cockapoo"
3: "terrier/russell"
4: "hound/blood"
length: 5

You can find the code for this step on the build_questions_list branch.

Displaying the Questions

In this step, we want to be able to display to the player a picture of each of the dog breeds that are in the questions array step by step.

We will begin by creating a new Question component.

It receives the question prop, which represents the dog breed/sub-breed for which the image needs to be displayed.
We also pass it the setErrorMessage function that could be useful to set an error message in case of API request failure.

We create an imgSrc state variable, initialized to null. It will store the image url when we get it from the API.
In the useEffect hook, we fetch a random image url for the breed corresponding to the question at the following API endpoint "https://dog.ceo/api/breed/${breed}/images/random".
If the response is successful, we parse the data to extract the image url and set it to our imgSrc state.
If the response is not successful, we set an error message using the setErrorMessage function.

The component returns a div element, which contains an img element with the src attribute set to the imgSrc state variable value.
This displays the dog image in the UI once it is fetched successfully.

src/components/Question.jsx

import { useEffect, useState } from 'react'

const Question = ({ question, setErrorMessage }) => {
  const [imgSrc, setImgSrc] = useState(null)

  const getQuestionImg = async (breed) => {
    const res = await fetch(`https://dog.ceo/api/breed/${breed}/images/random`)
    if (res.ok) {
      const data = await res.json()
      setImgSrc(data.message)
    } else {
      setErrorMessage('Something went wrong!')
    }
  }

  useEffect(() => {
    const fetchImg = async () => {
      await getQuestionImg(question)
    }
    fetchImg()
  }, [question])

  if (imgSrc === null) {
    return null
  }

  return (
    <div className='imgContainer'>
      <img src={imgSrc} />
    </div>
  )
}

export default Question

Then we import the new Question component in our Quiz component.

We add a new questionIndex state variable, initialized to null. We create a question variable, equal to questions[questionIndex], which will be the question currently being displayed, that we can pass to our Question component.
In the buidQuestionsList function, called when the player clicks on the "Start Quiz" button, we initialize questionIndex to 0 after building our questions list.
The displayNextQuestion function will increment the questionIndex by one, it will be called when the player clicks on the "Next Question" button.

The Quiz component will render the "Start Quiz" button if the quiz has not started yet, i.e. questionIndex is null, or the Question component and the "Next Question" button otherwise.

src/components/Quiz.jsx

import { useState, useEffect } from 'react'
import shuffleArray from '../utils/shuffleArray'
import Button from './Button'
import Question from './Question'

const Quiz = () => {
  const [errorMessage, setErrorMessage] = useState(null)
  const [breeds, setBreeds] = useState([])
  const [questions, setQuestions] = useState([])
  const [questionIndex, setQuestionIndex] = useState(null)

  const QUIZ_LENGTH = 5

  const buidQuestionsList = () => {
    const breedsArray = shuffleArray(breeds).slice(0, QUIZ_LENGTH)
    setQuestions(breedsArray)
    setQuestionIndex(0)
  }

  const displayNextQuestion = async () => {
    const nextIndex = questionIndex + 1
    if (nextIndex < questions.length) {
      setQuestionIndex(nextIndex)
    }
  }

  const question = questionIndex !== null ? questions[questionIndex] : null

  useEffect(() => {
    const fetchBreeds = async () => {
      const res = await fetch('https://dog.ceo/api/breeds/list/all')
      if (res.ok) {
        const data = await res.json()
        const tempBreeds = []
        for (let breed in data.message) {
          if (data.message[breed].length > 0) {
            tempBreeds.push(
              ...data.message[breed].map((subBreed) => `${breed}/${subBreed}`)
            )
          } else {
            tempBreeds.push(breed)
          }
        }
        setBreeds(tempBreeds)
      } else {
        setErrorMessage('Something went wrong!')
      }
    }
    fetchBreeds()
  }, [])

  if (errorMessage) {
    return (
      <main>
        <div className='error'>{errorMessage}</div>
      </main>
    )
  }

  return (
    <main>
      {questionIndex !== null ? (
        <>
          <Question question={question} setErrorMessage={setErrorMessage} />
          <Button onClick={displayNextQuestion}>Next Question</Button>
        </>
      ) : (
        <Button onClick={buidQuestionsList}>Start Quiz</Button>
      )}
    </main>
  )
}

export default Quiz

We are now able to browse through our 5 dog pictures.

You can find the code for this step on the display_questions branch.

Displaying the Choices

In this step, we want to display four possible answers for each questions, i.e. the correct answer and three other wrong answers, in a random order.

For this we will begin by creating a new Choices component.

It receives several props including breeds, which is an array of all possible choices, question, which is the correct answer, and onSelectChoice, which is a callback function to handle changes in the selected choice.

We create a choices state variable, initialized to an empty array.
Then in the useEffect hook, when the component mounts or when the question prop changes, we build our choices array from the correct answer and a random array of three other breeds, and we shuffle these choices so that they are displayed in a random order.

We add an onChange listener on the radio inputs, which will call the handleChoiceChange function. This function pass the selected choice value to the onSelectChoice function received as a prop.

The component then renders a list of radio inputs, one for each possible choice.

src/components/Choices.jsx

import { useState, useEffect } from 'react'
import shuffleArray from '../utils/shuffleArray'

const Choices = ({ breeds, question, onSelectChoice }) => {
  const [choices, setChoices] = useState([])

  useEffect(() => {
    const correctChoice = question
    const tmpChoices = [
      correctChoice,
      ...shuffleArray(breeds)
        .slice(0, 4)
        .filter((breed) => breed !== correctChoice)
        .slice(0, 3),
    ]
    setChoices(shuffleArray(tmpChoices))
  }, [question])

  const handleChoiceChange = (e) => {
    onSelectChoice(e.target.value)
  }

  return (
    <div className='choices'>
      {choices.length &&
        choices.map((choice) => (
          <div className='choice' key={choice}>
            <input
              id={choice}
              name='choice'
              type='radio'
              value={choice}
              onChange={handleChoiceChange}
            />
            <label htmlFor={choice}>
              {choice.split('/').reverse().join(' ')}
            </label>
          </div>
        ))}
    </div>
  )
}

export default Choices

Then we import the new Choices component in our Quiz component.

We add a new answers state variable, initialized to an empty array. It will hold the list of answers given by the player.
We create the handleSelectChoice function that we pass as the onSelectChoice prop of our Choices component. It will update the answers array according to the player choices.
We also disable the "Next Question" button while the player has not chosen an answer for the current question.

src/components/Quiz.jsx

import { useState, useEffect } from 'react'
import shuffleArray from '../utils/shuffleArray'
import Button from './Button'
import Question from './Question'
import Choices from './Choices'

const Quiz = () => {
  const [errorMessage, setErrorMessage] = useState(null)
  const [breeds, setBreeds] = useState([])
  const [questions, setQuestions] = useState([])
  const [questionIndex, setQuestionIndex] = useState(null)
  const [answers, setAnswers] = useState([])

  const QUIZ_LENGTH = 5

  const buidQuestionsList = () => {
    const breedsArray = shuffleArray(breeds).slice(0, QUIZ_LENGTH)
    setQuestions(breedsArray)
    setQuestionIndex(0)
  }

  const displayNextQuestion = async () => {
    const nextIndex = questionIndex + 1
    if (nextIndex < questions.length) {
      setQuestionIndex(nextIndex)
    }
  }

  const question = questionIndex !== null ? questions[questionIndex] : null

  useEffect(() => {
    const fetchBreeds = async () => {
      const res = await fetch('https://dog.ceo/api/breeds/list/all')
      if (res.ok) {
        const data = await res.json()
        const tempBreeds = []
        for (let breed in data.message) {
          if (data.message[breed].length > 0) {
            tempBreeds.push(
              ...data.message[breed].map((subBreed) => `${breed}/${subBreed}`)
            )
          } else {
            tempBreeds.push(breed)
          }
        }
        setBreeds(tempBreeds)
      } else {
        setErrorMessage('Something went wrong!')
      }
    }
    fetchBreeds()
  }, [])

  const handleSelectChoice = (selectedChoice) => {
    const newAnswers = [...answers]
    newAnswers[questionIndex] = selectedChoice
    setAnswers(newAnswers)
  }

  if (errorMessage) {
    return (
      <main>
        <div className='error'>{errorMessage}</div>
      </main>
    )
  }

  return (
    <main>
      {questionIndex !== null ? (
        <>
          <Question question={question} setErrorMessage={setErrorMessage} />
          <Choices
            breeds={breeds}
            question={question}
            onSelectChoice={handleSelectChoice}
          />
          <Button
            onClick={displayNextQuestion}
            disabled={answers[questionIndex] === undefined}
          >
            Next Question
          </Button>
        </>
      ) : (
        <Button onClick={buidQuestionsList}>Start Quiz</Button>
      )}
    </main>
  )
}

export default Quiz

You can find the code for this step on the display_choices branch.

We are nearly done, at this point you should have something like this:

Screenshot of a dog quiz application, with the picture of a dog and several breed choices

Displaying the Results

The last step in the creation of our quiz, is to display the results after the last question.

We create a new Results component.

It receives two props: questions, which is the array of questions, and answers, which is the array of corresponding answers provided by the user.

The score variable is calculated using the reduce method on the questions array. The reduce method iterates over each question in the questions array and accumulates a score (acc) based on whether the user's answer (answers[index]) matches the correct answer (question). If the user's answer matches the correct answer, the acc is incremented by 1. The initial value of acc is set to 0.

The component then renders the player's score as well as the list of correct and givent answers.

src/components/Results.jsx

const Results = ({ questions, answers }) => {
  const score = questions.reduce(
    (acc, question, index) => (question === answers[index] ? acc + 1 : acc),
    0
  )

  return (
    <div>
      <div className='result'>
        <h2>
          Your score: {score}/{questions.length}
        </h2>
      </div>
      {questions.map((question, index) => (
        <div className='result' key={question}>
          <div>Correct answer: {question?.split('/').reverse().join(' ')}</div>
          <div>
            Your answer:{' '}
            <span className={question === answers[index] ? 'right' : 'wrong'}>
              {answers[index]?.split('/').reverse().join(' ')}
            </span>
          </div>
        </div>
      ))}
    </div>
  )
}

export default Results

Finally, we import the new Results component in our Quiz component.

We create a new displayResults state variable, initialized to false. If this variable is true, our component will render the Results component as well as a "Start New Quiz" button which will call the buidQuestionsList function on click. In the buidQuestionsList function, we need to reset our displayResults , answers and questionIndex state variables to their initial values.

We will also change the text and the click handler for the "Next Question" button that is below the choices list when we are on the last question, it will become a "See Result" button which will set displayResults to true on click.

src/components/Quiz.jsx

import { useState, useEffect } from 'react'
import shuffleArray from '../utils/shuffleArray'
import Button from './Button'
import Question from './Question'
import Choices from './Choices'
import Results from './Results'

const Quiz = () => {
  const [errorMessage, setErrorMessage] = useState(null)
  const [breeds, setBreeds] = useState([])
  const [questions, setQuestions] = useState([])
  const [questionIndex, setQuestionIndex] = useState(null)
  const [answers, setAnswers] = useState([])
  const [displayResults, setDisplayResults] = useState(false)

  const QUIZ_LENGTH = 5

  const buidQuestionsList = () => {
    setDisplayResults(false)
    setAnswers([])
    setQuestionIndex(null)
    const breedsArray = shuffleArray(breeds).slice(0, QUIZ_LENGTH)
    setQuestions(breedsArray)
    setQuestionIndex(0)
  }

  const displayNextQuestion = async () => {
    const nextIndex = questionIndex + 1
    if (nextIndex < questions.length) {
      setQuestionIndex(nextIndex)
    }
  }

  const question = questionIndex !== null ? questions[questionIndex] : null

  useEffect(() => {
    const fetchBreeds = async () => {
      const res = await fetch('https://dog.ceo/api/breeds/list/all')
      if (res.ok) {
        const data = await res.json()
        const tempBreeds = []
        for (let breed in data.message) {
          if (data.message[breed].length > 0) {
            tempBreeds.push(
              ...data.message[breed].map((subBreed) => `${breed}/${subBreed}`)
            )
          } else {
            tempBreeds.push(breed)
          }
        }
        setBreeds(tempBreeds)
      } else {
        setErrorMessage('Something went wrong!')
      }
    }
    fetchBreeds()
  }, [])

  const handleSelectChoice = (selectedChoice) => {
    const newAnswers = [...answers]
    newAnswers[questionIndex] = selectedChoice
    setAnswers(newAnswers)
  }

  if (errorMessage) {
    return (
      <main>
        <div className='error'>{errorMessage}</div>
      </main>
    )
  }

  if (displayResults) {
    return (
      <main>
        <Results questions={questions} answers={answers} />
        <Button onClick={buidQuestionsList}>Start New Quiz</Button>
      </main>
    )
  }

  return (
    <main>
      {questionIndex !== null ? (
        <>
          <Question question={question} setErrorMessage={setErrorMessage} />
          <Choices
            breeds={breeds}
            question={question}
            onSelectChoice={handleSelectChoice}
          />
          <Button
            onClick={
              questionIndex < questions.length - 1
                ? displayNextQuestion
                : () => setDisplayResults(true)
            }
            disabled={answers[questionIndex] === undefined}
          >
            {questionIndex < questions.length - 1
              ? 'Next Question'
              : 'See Results'}
          </Button>
        </>
      ) : (
        <Button onClick={buidQuestionsList}>Start Quiz</Button>
      )}
    </main>
  )
}

export default Quiz

You can find the code for this step on the display_results branch.

The results screen should look like this:

Screenshot of the results screen of a dog quiz application

And that's it, our dog quiz is done! 🐕

I hope this was fun to build, you can of course expand on this application to add some functionalities, like a timer for instance.

You can see an online demo here.

By the way, if you like quizzes and if you have an android device, you can try my Kwiz2 application that I built some times ago with React Native.

Last updated 1 year ago