TOEIC対策に役立つWebアプリを開発しました。Next.jsとGoogle Sheets APIを活用し、非同期処理で問題データをスムーズに読み込み、快適な操作性を実現しています。
作成したWebアプリケーション↓
このWebアプリを制作過程を紹介します。
環境構築
Next.js のプロジェクトを新規作成
新しいフォルダを作成して、コマンドnpx create-next-app
で、フォルダ内にNext.jsのプロジェクトを新規作成します。
また、cssでのスタイリングはTailwindを使用していますが、当ページ記載のソースコードでは、Tailwindに関する記述を省略しています。(コードがごちゃつくので)
初期画面の作成
スタート画面を作成し、正誤を一問ごと確認するか、全問解いてから確認するか選べるようにしました。
import Link from "next/link";
import { useState } from "react";
const Home = () => {
const [showLink, setShowLink] = useState(false);
const [seeAnswer, setSeeAnswer] = useState(null);
// 正誤の確認方法を選択したら、スタートボタンを表示させる関数
const clicked = (e) => {
setShowLink(true);
setSeeAnswer(e.target.value);
};
return (
<div>
<h1 >TOEIC No.5 練習問題</h1>
<p></p>
<fieldset>
<div>
<input type="radio" id="each" name="explanations" value="each" onChange={clicked} />
<label for="each">一問ごとに正誤を確認する</label>
</div>
<div>
<input type="radio" id="end" name="explanations" value="end" onChange={clicked} />
<label for="end">全ての問題を回答してから正誤を確認する</label>
</div>
</fieldset>
{showLink &&
// 正誤を問題毎に確認するかどうかを、クエリパラメータで渡す
<Link href={`/question?seeAnswer=${seeAnswer}`}>START</Link>
}
</div>
);
};
export default Home;
スプレッドシートからデータをフェッチ
React及びNext.jsでのWebサイト、アプリはいくつか作ってきましたが、今回のポイントは、APIからデータをフェッチ(非同期処理で取得)したことです。
非同期処理の概念は、少し難しく、プログラミング学習者の定番の挫折ポイントです。
非同期処理への理解を深め、実用できるようにするために今回、非同期処理お使ったアプリを開発しました。
問題のデータを記載したスプレッドシートを用意し、Google Sheets APIを活用して、データをjson形式で取得できるようにします。
データのフェッチにはこちらの記事を参考にさせていただきました。
const API_URL = `https://sheets.googleapis.com/v4/spreadsheets/${process.env.NEXT_PUBLIC_GOOGLE_SHEETS_DOC_ID}/values/toeic?key=${process.env.NEXT_PUBLIC_GOOGLE_SHEETS_API_KEY}`
const fetchQuestions = async () => {
try {
const response = await fetch(API_URL);
// Googleスプレッドシートから取得したjsonデータを解析し、正しい連想配列に変換する
const data = await response.json();
const formattedQuestions = data.values.slice(1).map((questionRow) => {
return {
question: questionRow[0],
choices: [[questionRow[1]], [questionRow[2]], [questionRow[3]], [questionRow[4]]],
answer: [questionRow[5]],
}
});
return formattedQuestions;
} catch (error) {
console.error(error);
return [];
}
};
export default fetchQuestions;
回答ページを作成
データから問題を1問ずつ表示される仕様の回答ページを作成しました。
各問題で回答した選択肢は、useSearchParams()
フックを使って、クエリパラメータで次のページに渡します。
import { useState, useEffect, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import fetchQuestions from "../datas/api";
const QuestionContent = () => {
//クエリパラメータの値を取得する
const router = useRouter();
const params = useSearchParams();
const seeAnswer = params.get('seeAnswer');
const [currentNum, setCurrentNum] = useState(0); //今何問目
const [selectedValue, setSelectedValue] = useState(null); //選択された値
const [displayExplanation, setDisplayExplanation] = useState(false); //結果を表示する
const [judgment, setJudgment] = useState(false); //正誤判定
const [addAnswers, setAddAnswers] = useState([]); //回答を追加
const [loading, setLoading] = useState(true); //ローディング画面
// スプレッドシートから取得したデータ
const [questionsData, setQuestionsData] = useState([]);
useEffect(() => {
const fetchData = async () => {
const questions = await fetchQuestions();
setQuestionsData(questions);
setLoading(false);
}
fetchData();
}, []);
// ラジオボタン選択
const handleSelect = (e) => {
setJudgment(true);
setSelectedValue(e.target.value);
}
// 正誤を表示
const handleDisplayExplanation = () => {
setDisplayExplanation(true);
setJudgment(false);
}
// 次の問題へ移動
const handleNextButton = () => {
if (selectedValue !== null) {
setAddAnswers([...addAnswers, selectedValue]);
} else {
setAddAnswers([...addAnswers, '未回答']);
}
setCurrentNum(currentNum + 1);
setSelectedValue(null);
setDisplayExplanation(false);
const radioButtons = document.querySelectorAll('input[name="drone"]');
radioButtons.forEach(radioButton => radioButton.checked = false);
}
// 前の問題
const handlePrevButton = () => {
setCurrentNum(currentNum - 1);
const radioButtons = document.querySelectorAll('input[name="drone"]');
radioButtons.forEach((radioButton) => (radioButton.checked = false));
}
// 結果を見る
const gotoResultPage = () => {
const filteredAnswers = addAnswers.filter((answer) => answer !== null); // nullを除外した回答配列を作成
setAddAnswers([...addAnswers, selectedValue]);
// 結果を表示するためのクエリパラメータを出力する
const answerParams = filteredAnswers.map((str, index) => (
`Q${index + 1}=${str}${index === addAnswers.length - 1 ? '' : '&'}`
)).join('');
router.push(`/result?${answerParams}`);
}
return (
<div>
{/* 問題文 */}
<p>
Q{currentNum + 1}. /{questionsData.length}<br />
{loading ? "問題を読み込み中..." : questionsData[currentNum]?.question}
</p>
{/* 選択肢 */}
<fieldset>
{loading ? "問題を読み込み中..." : questionsData[currentNum]?.choices.map((choice, index) => (
<div key={index}>
<input type="radio" id={choice[0]} name="drone" value={choice[0]} onChange={handleSelect} />
<label for={choice[0]}>{choice[0]}</label>
</div>
))}
</fieldset>
{/* 正誤確認ボタン */}
{seeAnswer === 'each' && judgment && (
<button type="button" onClick={handleDisplayExplanation}>正誤を確認する</button>
)}
{/* 正誤判定 */}
{displayExplanation && (
selectedValue === questionsData[currentNum].answer[0] ? (
<div>〇 正解!!</div>
) : (
<div>✕<br /><span>正解は、{questionsData[currentNum].answer}</span></div>
)
)}
{/* ボタン */}
<div>
{currentNum !== 0 ? (<button type="button" onClick={handlePrevButton}>前の問題へ</button>) : ""}
{loading ? "問題を読み込み中..." : questionsData.length - 1 === currentNum ? (
<button onClick={gotoResultPage}>結果を見る</button>
) : (
<button type="button" onClick={handleNextButton}>次の問題へ</button>
)}
</div>
</div>
)
}
const Question = () => {
return (
<Suspense>
<QuestionContent />
</Suspense>
);
};
export default Question;
ResultContent
コンポーネントをSuspense
コンポーネントで囲っているのは、useSearchParams()
を使用するコンポーネントでは、ページがレンダリングされないエラーが発生するためです。
参考↓
回答結果を表示するページの作成
問題をすべて回答したら表示される結果発表のページです。
map()
メソッドで、各問題のデータを一覧で表示させます。
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import fetchQuestions from "../datas/api";
const ResultContent = () => {
const params = useSearchParams();
const answers = [];
// URLパラメーターから回答データを取得する
for (const [key, value] of params.entries()) {
const questionNumber = parseInt(key.substring(1));
answers[questionNumber - 1] = value;
}
// スプレッドシートから取得したデータ
const [questionsData, setQuestionsData] = useState([]);
useEffect(() => {
const fetchData = async () => {
const questions = await fetchQuestions();
setQuestionsData(questions);
}
fetchData();
}, []);
const renderResultItem = (questionIndex) => {
const question = questionsData[questionIndex];
const userAnswer = answers[questionIndex];
const correctAnswer = question.answer;
return (
<div key={questionIndex}>
<h2>Q{questionIndex + 1}. {userAnswer === correctAnswer ? (
<span>〇</span>
) : (
<span>✕</span>
)}</h2>
<p>Q. {question.question}</p>
<div>
<p>Choices:</p>
<ul>
<li>{question.choices[0]}, </li>
<li>{question.choices[1]}, </li>
<li>{question.choices[2]}, </li>
<li>{question.choices[3]}</li>
</ul>
</div>
<p>あなたの回答: {userAnswer}</p>
<p>正解: {correctAnswer}</p>
</div>
);
};
return (
<div>
<h1>Result</h1>
<div>{questionsData.map((answer, index) => renderResultItem(index))}</div>
<Link href="/">最初の画面に戻る</Link>
</div>
)
}
const Result = () => {
return (
<Suspense>
<ResultContent />
</Suspense>
);
};
export default Result;
デプロイ
Webサーバーの公開にはVercelを利用しました。
デプロイされたページを見てみると、Google Shhets APIが働いておらず、スプレのデータが読み込まれていませんでした。
Qiitaの質問機能で、解決方法を募ったところ、@uasiさんという方から、回答いただきました。
デプロイした後に、Vercel側で環境変数(apiキー)をしたのですが、どうやらそれがダメだったようです。
CLI commandで再デプロイしたところ、無事apiがデータを読み込んでくれていました。
Webアプリとして多少荒削りな部分があるので、適宜改善を加えて行こうと思います。