1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

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

環境構築

Next.js のプロジェクトを新規作成

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

また、cssでのスタイリングはTailwindを使用していますが、当ページ記載のソースコードでは、Tailwindに関する記述を省略しています。(コードがごちゃつくので) 

初期画面の作成

toeic.jpg
スタート画面を作成し、正誤を一問ごと確認するか、全問解いてから確認するか選べるようにしました。

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

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

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
データから問題を1問ずつ表示される仕様の回答ページを作成しました。
各問題で回答した選択肢は、useSearchParams()フックを使って、クエリパラメータで次のページに渡します。

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()を使用するコンポーネントでは、ページがレンダリングされないエラーが発生するためです。

参考↓

回答結果を表示するページの作成

toeic3.jpg
問題をすべて回答したら表示される結果発表のページです。
map()メソッドで、各問題のデータを一覧で表示させます。

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を利用しました。

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

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

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

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?