Next.jsでTOEIC対策！Google Sheets APIを活用したクイズWebアプリを開発

TOEIC対策に役立つWebアプリを開発しました。Next.jsとGoogle Sheets APIを活用し、非同期処理で問題データをスムーズに読み込み、快適な操作性を実現しています。

作成したWebアプリケーション↓

https://toeic-five.vercel.app/

このWebアプリを制作過程を紹介します。

## 環境構築
### Next.js のプロジェクトを新規作成
新しいフォルダを作成して、コマンド`npx create-next-app`で、フォルダ内にNext.jsのプロジェクトを新規作成します。

また、cssでのスタイリングは[Tailwind](https://tailwindcss.com/)を使用していますが、当ページ記載のソースコードでは、Tailwindに関する記述を省略しています。（コードがごちゃつくので）　
## 初期画面の作成
![toeic.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1902958/cc7978bd-82a9-4049-f5f4-e32376cb3003.jpeg)
スタート画面を作成し、正誤を一問ごと確認するか、全問解いてから確認するか選べるようにしました。
```jsx:page.jsx
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形式で取得できるようにします。

![sheet.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1902958/4847155e-23ad-7366-98a5-474d964a1e63.jpeg)

データのフェッチにはこちらの記事を参考にさせていただきました。

https://qiita.com/sin164/items/2addcf88dda4844d5219

```jsx:datas/api.jsx
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;
```
## 回答ページを作成
![toeic2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1902958/3195657c-510b-bb89-c377-037ce7f73ea1.jpeg)
データから問題を1問ずつ表示される仕様の回答ページを作成しました。
各問題で回答した選択肢は、`useSearchParams()`フックを使って、クエリパラメータで次のページに渡します。
```jsx:question/page.jsx
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()`を使用するコンポーネントでは、ページがレンダリングされないエラーが発生するためです。

参考↓

https://inari-tech.net/posts/use-suspense-searchparams

https://nextjs.org/docs/messages/deopted-into-client-rendering
## 回答結果を表示するページの作成
![toeic3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1902958/0f48e9d3-b74f-feef-2d0c-e1631f0550af.jpeg)
問題をすべて回答したら表示される結果発表のページです。
`map()`メソッドで、各問題のデータを一覧で表示させます。
```jsx:result/page.jsx
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](https://vercel.com/)を利用しました。

デプロイされたページを見てみると、Google Shhets APIが働いておらず、スプレのデータが読み込まれていませんでした。
Qiitaの質問機能で、解決方法を募ったところ、[@uasi](https://qiita.com/uasi)さんという方から、回答いただきました。
デプロイした後に、Vercel側で環境変数（apiキー）をしたのですが、どうやらそれがダメだったようです。

https://qiita.com/yamashee/questions/2c14f4abcac1cf7f1dc5

CLI commandで再デプロイしたところ、無事apiがデータを読み込んでくれていました。

https://toeic-five.vercel.app/

Webアプリとして多少荒削りな部分があるので、適宜改善を加えて行こうと思います。