0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

続続・クイズアプリを作成しながらReact+TypeScriptの練習

0
Last updated at Posted at 2026-01-21

ReactとTypeScriptについて、コードに慣れることを目的としてクイズアプリを作成中です。

問題が発生

前回の記事の最後に書いたように、問題に回答した後、2、3回同じ問題がたて続けに出題される現象が発生。

APIのドキュメントを見てみると下記の表示を見つけました。

The API appends a "Response Code" to each API Call to help tell developers what the API is doing.
(途中省略)
Code 5: Rate Limit Too many requests have occurred. Each IP can only access the API once every 5 seconds.

つまりAPIは5秒以内複数のリクエストを送るとエラーを返すという仕組みになっていたのです。

クイズが出題されてから速攻で回答すれば、1秒もかかりません。そうすると次の問題を取ってこようとしてもエラーが返ってきて手元のクイズが更新されず、同じ問題が出題されてしまうというわけです。

打開策を考えた

これまでは

  1. クイズに回答
  2. APIを叩いて新たなクイズを取得
  3. 別のクイズを出題

というシステムにしていたのですが、5秒待つのではテンポが悪く、クイズアプリとしては楽しくありません。そこで

正解・不正解関係なく10問の問題に次々に回答していって、10問中何問正解したかを最後に表示してくれるアプリ

に方針転換することにしました。Open Trivia DBのAPIは一度に10問取得することも可能。

  1. APIから10問分のクイズを取得
  2. クイズを配列に格納
  3. 配列のクイズを10問次々に出題←いちいちAPIを叩かなくてもよいので5秒待つ必要もない

上記のようなシステムを作ろうと決めました。

App.tsx
import { useState, useEffect } from 'react';
import ResultView from './ResultView';
import './App.css';

interface QuizData {
  question: string;
  correct_answer: string;
}

// ①クイズをfetchする部分を切り出す
const fetchQuizData = async (): Promise<QuizData[]> => {
  const res = await fetch("https://opentdb.com/api.php?amount=10&type=boolean");
  const data = await res.json();
  if (data.response_code === 0){
    return data.results;
  }else{
    throw new Error(`APIエラー コード: ${data.response_code}`);
  }
}

function App() {
  const [questions, setQuestion] = useState<QuizData[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [count, setCount] = useState<number>(1);
  const [score, setScore] = useState<number>(0);

  const fetchQuiz = async () => {
    setLoading(true);
    try {
      // ②一気に10問リクエストする
      const results = await fetchQuizData();
      setQuestion(results);
    } catch (error) {
      console.error("エラー:", error);
    } finally {
      setLoading(false);
    }
  };

  const decodeHTML = (html: string) => {
    const txt = document.createElement("textarea");
    txt.innerHTML = html;
    return txt.value;
  };

  // ③最初にクイズ10問を一気に取得、その後は取得しないので第2引数は空にした
  useEffect(() => {
    fetchQuiz();
  }, []);

  // ④10問解き終わったかどうかの判定
  const isFinished = count > 10;

  const currentQuestion = questions[count - 1];
  const handleAnswer = async (userAnswer: string) => {
    if (userAnswer === currentQuestion.correct_answer) {
      alert("正解!🎉 次の問題に進みます。");
      setScore((prev) => prev + 1);
    } else {
      alert("残念!😢 次の問題に進みます。");
    }

    setCount((prev) => prev + 1);
  };

  // ⑤10問終わっていたら ResultView を出す
  // カウント、スコアを初期化してクイズを再取得
  if (isFinished) {
    return (
      <div className="container">
        <ResultView score={score} onReset={() => {
                                            setCount(1);
                                            setScore(0);
                                            fetchQuiz();
                                          }} />
      </div>
    );
  }

  return (
    <div className="container">
      <h1>{count}</h1>
      <p style={{ fontWeight: 'bold', color: '#00b894' }}>現在の正解数: {score}</p>

      {loading || !currentQuestion ? <p>読み込み中...</p> : (
        <>
          <p className="question-text">{decodeHTML(currentQuestion.question)}</p>
          <div className="button-group">
            <button className="btn-true" onClick={() => handleAnswer("True")}>True</button>
            <button className="btn-false" onClick={() => handleAnswer("False")}>False</button>
          </div>
        </>
      )}
    </div>
  );
}

export default App;

コード解説

1. クイズをfetchする部分を切り出す
const fetchQuizData = async (): Promise<QuizData[]> => {
  const res = await fetch("https://opentdb.com/api.php?amount=10&type=boolean");
  const data = await res.json();
  if (data.response_code === 0){
    return data.results;
  }else{
    throw new Error(`APIエラー コード: ${data.response_code}`);
  }
}

APIを叩いてデータを整形する fetchQuizData を、App コンポーネントの外側へ移動させました。App はどう表示するかという画面を管理、外の関数はどうデータを取るかという通信を管理。関数の再生成による無駄なメモリ消費や無限ループのリスクを回避し、コードの「可読性」が向上。

2. fetchQuizData関数を呼び出してクイズを変数に格納
const results = await fetchQuizData();

1にある通り、10問分のクイズとその解答を取得して、配列としてresult変数に格納。

3. useEffectでクイズ10問を1回だけ取得
useEffect(() => {
  fetchQuiz();
}, []);

2番めの引数を空にして、最初に呼び出しを1回だけ行うことにしました。ここでクイズ10問を一度に取得するので、5秒以内に何度もAPIを叩く→エラーが返ってくる→クイズが更新されずに同じ問題が出題される、ということもなくなるはず。

4. 10問解き終わったかどうかの判定
const isFinished = count > 10;

countが10になるとisFinished関数がtrueになる。つまりこの関数で10問のクイズをとき終わったかどうかを判定。

5. 10問終わっていたら ResultView を呼び出して表示
  if (isFinished) {
    return (
      <div className="container">
        <ResultView score={score} onReset={() => {
                                            setCount(1);
                                            setScore(0);
                                            fetchQuiz();
                                          }} />
      </div>
    );
  }
if (isFinished) {

このif節により10問回答し終わったらこの中のJSXをreturnする(=resultViewを表示する)という挙動を付与しました。逆にいうと、10問目に回答し終わるまではこのif節はスキップしてクイズを出し続ける。

ResultViewについては別ファイルで定義しています。結果を表示し、ユーザーがもう一度挑戦するという文字をクリックするとonReset関数が発火する仕組み。onRest関数ではsetCount、setScoreでcountとscoreの値を初期値に戻し、fetchQuiz関数で新たにクイズ10問を取得して再びクイズを開始します。

ResultViewファイルは下記。分割代入でscoreとonReset関数の戻り値の型を指定しています。

ResultView.tsx
interface ResultProps {
  score: number;
  onReset: () => void;
}

const ResultView = ({ score, onReset }: ResultProps) => (
  <div className="result-screen">
    <h1>クイズ終了!</h1>
    <p className="score-display">あなたの正解数は <strong>{score}</strong> / 10 です</p>
    <button onClick={onReset}>もう一度挑戦する</button>
  </div>
);

export default ResultView;
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?