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秒もかかりません。そうすると次の問題を取ってこようとしてもエラーが返ってきて手元のクイズが更新されず、同じ問題が出題されてしまうというわけです。
打開策を考えた
これまでは
- クイズに回答
- APIを叩いて新たなクイズを取得
- 別のクイズを出題
というシステムにしていたのですが、5秒待つのではテンポが悪く、クイズアプリとしては楽しくありません。そこで
正解・不正解関係なく10問の問題に次々に回答していって、10問中何問正解したかを最後に表示してくれるアプリ
に方針転換することにしました。Open Trivia DBのAPIは一度に10問取得することも可能。
- APIから10問分のクイズを取得
- クイズを配列に格納
- 配列のクイズを10問次々に出題←いちいちAPIを叩かなくてもよいので5秒待つ必要もない
上記のようなシステムを作ろうと決めました。
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関数の戻り値の型を指定しています。
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;