はじめに
ReactとTypeScriptについて一通り学んだので、コードに慣れることを目的として何かアプリを作りたいと思いました。せっかくなので記事として残しておこうと思いました。あちこちで調べて悪戦苦闘して理解した内容をメモとして細かく書いています。
やること
- APIから問題を取得
- 問題と選択肢を表示
- 答えをチェックしてアラートを出す
準備
クイズ用のAPIを準備。今回はOpen Trivia DBというものを使いました。会員登録が不要でURLを叩くだけで誰でも使える、ジャンルが多彩、URLの最後にあるパラメータを書き換えると取得する内容が変えられるので便利でした。
今回はNumber of Questionを1に設定、Typeをbooleanに設定したところ下記のjsonデータが返ってきていました。本題とは関係ないですが、APIを叩くたびにいろんな問題が返ってくるので結構楽しいです。日本関連のクイズもかなり多い印象です。
{"response_code":0,"results":[{"type":"boolean","difficulty":"easy","category":"Science & Nature","question":"The shape of the Earth is a perfect sphere.","correct_answer":"False","incorrect_answers":["True"]}]}
最初にこのようなコードに至りました。
import { useState, useEffect } from 'react';
import './App.css';
interface QuizData {
question: string;
correct_answer: string;
}
function App() {
// ① 最初はnullが入るので型を<QuizData | null> にした
const [question, setQuestion] = useState<QuizData | null>(null);
// 読み込み中かどうかを管理するState
const [loading, setLoading] = useState<boolean>(true);
const fetchQuiz = async () => {
setLoading(true);
try {
const res = await fetch("https://opentdb.com/api.php?amount=1&type=boolean");
const data = await res.json();
// APIの結果をStateに入れる
setQuestion(data.results[0]);
} catch (error) {
console.error("エラーが発生しました:", error);
} finally {
setLoading(false);
}
};
// ② "などが含まれるのでこれを " にデコードする関数
const decodeHTML = (html: string) => {
const txt = document.createElement("textarea");
txt.innerHTML = html;
return txt.value; // " が " になった純粋な文字列が手に入る
};
// ③ 初回表示時に一度だけ実行
useEffect(() => {
fetchQuiz();
}, []);
const handleAnswer = (userAnswer: string) => {
// questionがnullの場合は何もしない(ガード)
if (!question) return;
if (userAnswer === question.correct_answer) {
alert("正解!");
} else {
alert("残念...");
}
};
// ロード中の表示
if (loading) {
return (
<div className="container">
<p className="question-text">読み込み中...</p>
</div>
);
}
// ④ questionがnullでないことを確認してから表示
if (!question) return null;
return (
<div className="container">
<h1>クイズ!</h1>
<p className="question-text">{decodeHTML(question.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;
① <QuizData | null>ユニオン型
const [question, setQuestion] = useState<QuizData | null>(null);
「通信が終わるまでは null、終わったら QuizData」 という2つの状態があり得るので、これを |(オア)を使ってどちらかの可能性があるよと教えてあげています。型に厳格なTypeScriptならではの書き方だと思います。
② デコード関数
const decodeHTML = (html: string) => {
const txt = document.createElement("textarea");
txt.innerHTML = html;
return txt.value; // " が " になった純粋な文字列が手に入る
};
APIから届くクイズ文には、" や ' といった特殊な記号が含まれていることがあります。これらは"や'のように表示させたいのに、Reactはセキュリティのためにこれらをそのまま表示します。この関数をつかってデコードしてしまうことで、ブラウザが正しく記号に変換して表示してくれます。
"Akira"
例えばこれは"Akira"と表示させたいのに、何もしないとそのまま表示されてしまう。この関数を使うことでブラウザが正しく記号に変換して表示してくれる。
③useEffect
// ③ 初回表示時に一度だけ実行
useEffect(() => {
fetchQuiz();
}, []);
2番目の引数が[]になっていて何も入っていないので、このuseEffectは最初に一度呼ばれて、その後は使われません。
④ if (!question) return;(型ガード)
// ④ questionがnullでないことを確認してから表示
if (!question) return;
TypeScriptは「question が null の可能性があるなら、question.question にアクセスしてはいけない」とエラーを出します。このif文を入れることで、「もし null だったらここで処理を終わらせる」と確定させます。するとそれ以降のコードでは question が null ではないことが保証されるため、安全にアクセスできるようになります。これも事前に型チェックをしてくれるTypeScriptならではと言えるかと思います。
次のステップへ
③にある通り、上記のコードでは最初にクイズを出題してそれで終わりとなってしまうので、コードを更新して次々とクイズを出題する形に変更しようと思います。肝となってくるのはuseEffectの2番目の引数になります。
import { useState, useEffect } from 'react';
import './App.css';
interface QuizData {
question: string;
correct_answer: string;
}
function App() {
const [question, setQuestion] = useState<QuizData | null>(null);
const [loading, setLoading] = useState<boolean>(true);
// 何問目かを管理するStateを追加
const [count, setCount] = useState<number>(1);
const fetchQuiz = async () => {
setLoading(true);
try {
const res = await fetch("https://opentdb.com/api.php?amount=1&type=boolean");
const data = await res.json();
setQuestion(data.results[0]);
} catch (error) {
console.error("エラー:", error);
} finally {
setLoading(false);
}
};
// "などが含まれるのでこれを " にデコードする関数
const decodeHTML = (html: string) => {
const txt = document.createElement("textarea");
txt.innerHTML = html;
return txt.value; // " が " になった純粋な文字列が手に入る
};
// ① countが変わるたびに新しいクイズを取得
useEffect(() => {
fetchQuiz();
}, [count]);
const handleAnswer = async (userAnswer: string) => {
if (!question) return;
if (userAnswer === question.correct_answer) {
alert("正解!🎉 2秒後に次の問題に進みます。");
// ② カウントを増やす(これがきっかけで useEffect が再実行される)
setCount((prev) => prev + 1);
} else {
alert("残念!😢 もう一度考えてみてね。");
}
};
if (loading) {
return (
<div className="container">
<h1>第 {count} 問</h1>
<p className="question-text">読み込み中...</p>
</div>
);
}
if (!question) return null;
return (
<div className="container">
{/* 何問目かを表示 */}
<h1>第 {count} 問</h1>
{/* decodeHTMLをここで使う */}
<p className="question-text">{decodeHTML(question.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;
① useEffectの第2引数 [count]
// ① countが変わるたびに新しいクイズを取得
useEffect(() => {
fetchQuiz();
}, [count]);
ここが最大のポイントだと思っているのですが、useEffect の第2引数に count を入れました。これにより、setCount で数字が増えるたびに、Reactが「お、useEffectの2番めの引数のcount が変わったな。じゃあ fetchQuiz をもう一回実行しよう」と自動で判断してくれます。React の データが変われば画面(や処理)も変わる という基本的な考え方です。
② setCount((prev) => prev + 1)
// ② カウントを増やす(これがきっかけで useEffect が再実行される)
setCount((prev) => prev + 1);
setCount(count + 1) と書いても動きますが、(prev) => prev + 1 と書くと今の値(prev)に1足すという確実な指示になります。非同期処理(awaitなど)が絡むときは、この書き方の方がバグが起きにくい。
動作確認
凄まじくシンプルですが、クイズが次々に出題されるアプリが稼働しました。実際に作ってみて理解できた部分もあったので、引き続き機能を追加するなどして学習しようと思います。

