【個人開発】誰でも100点取れて自己肯定感を爆上げできるクイズゲーム

## はじめに
この度、「誰でも100点が取れる（全問正解できる）」というコンセプトのクイズゲームを`React 19`, `TypeScript`, `vite`で作りました。デプロイ先は`Vercel`です。

皆様におきましては、常に全問正答して俺TUEEE（おれつえええ）という無双状態を体感し、自己肯定感を爆上げしてください。

https://quizgame-benjuwan.vercel.app/

## ゲームの特徴
先に述べたように、 **誰でも100点が取れる（全問正解できる）** をコンセプトにしています。

具体的には以下キャプチャのように、
**間違った回答を選ぶと強制的に選択不可**となり、
**回答が未チェックでは次の問題へは進めない**ため、
必然的に**正答しか選べない状況になる**のです。

![dareore-usecap.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/2dd24de3-f4f2-440a-873c-ee22daa43352.gif)

絶対に、間違いなく、100点を取れる、**勝利が約束されたクイズゲーム**なので、自己肯定感を爆上げしてください。

無事に全問正答すると「褒められ画面」に移ります。

![resultpage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/ca57a9e7-d234-4838-9be3-6f7c1a0b01eb.png)

余談ながら、上記の「褒め文章」や「各クイズの内容」は全て生成AIに用意してもらいました。
~~（だって、いちいち全部自分で用意してられないでしょ）~~

`ChatGPT`, `Claude`, `Gemini`の三方にお願いしましたが、一番`Gemini`がノリノリで作ってくれました。

### 用途
用途としては以下を想定しています。
- 脳トレ（認知症の予防とか）
間違った回答を選択した時点でヒントが表示されるので**振り返り + 脳への刺激になる**（かも？）

- 学習の振り返り
**語句や専門用語、構文などのチェックといった学習面**のほか、**定型的な業務フローの確認（新人教育）など業務面といった振り返りに活用**できる（かも？）

- 子どもの知育
**表示された画像を含めて楽しく学べる**（かも？）

- 一般的なクイズ
回答強制機能をオフにして一般的なクイズとして遊ぶこともできます。

---

現状、以下のカテゴリを準備しています。
- 一般教養
- 色々な動物たち
- 子どもの知育
- 地理・自然
- 歴史（主に日本について）
- ライフハック
- IT・コンピューター全般
- webサイト制作関連
- ビジネス・社会（※回答強制なし）

例：「色々な動物たち」カテゴリーのキャプチャ

![dareore-img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/208eb820-e253-4f6e-ab87-e4920b5cc78e.png)

---

「勝ちゲーなんて、馬鹿にしてるのか！」という方向けに「ビジネス・社会」カテゴリは回答強制機能をオフにしています。

ちなみに、回答強制のオフはシンプルにクイズデータ（jsonファイル）の当該設問に`adjust`を用意しなければいいだけです。

「コード修正してビルドして～」などせずとも当該クイズデータ（jsonファイル）を修正するだけで済みます。

- クイズデータ（jsonファイル）の一部
  - `choices`内の（各選択肢における）`point`が100のものが正解です
  - 間違いの回答に`adjust`を付けると回答強制機能がオンになります。単体・複数キーワードどちらでもokで、複数キーワードの場合はランダム表示となります。
  - `imgsrc`には参照画像も設定可能です
```json
{
    "quiz": "砂漠に住んでいて、コブが特徴的な動物は？",
    "imgsrc": "",
    "choices": {
        "one": {
            "txt": "カバ",
            "point": "0",
            "adjust": [
                "カバは水辺が好きだよね。",
                "コブはないし、砂漠には住まないよ。"
            ]
        },
        "two": {
            "txt": "ラクダ",
            "point": "100"
        },
        "three": {
            "txt": "ゾウ",
            "point": "10",
            "adjust": "ゾウも大きいけど、コブはないよね。"
        }
    }
},
{ ...
```

- ロゴについて
ロゴは生成AIではなく自作です。
「誰でも100点が取れて俺TUEEE（おれつえええ）」を漢字四文字にしたイメージです。

『最近イラレ触ってないなぁ～』と思いまして、アピアランスの使い方とか忘れかけてたので定期的に触るのは大事ですね。

## ところで、何でこんなん作ったん？
ただ`React 19`を試してみたかった、だけです。
そこで、以前`React 18`で作っていた個人開発をベースに今回作り変えました。

### `use`API
特に、事前告知の段階から[`use`API](https://ja.react.dev/reference/react/use)に関して気になっていたので今回クイズのデータフェッチ処理部分で試せて良かったです。

こんな感じです。
```tsx
// ...中略
export const FetchDataAndLoading = memo(() => {
    // 選んだクイズデータ
    const dynamicFetchPathUrl: string = `${selectQuiz.length !== 0 ? selectQuiz : selectQuizDefaultValue}/quiz.json`;

    // データフェッチするためのパス文字列
    const fetchPathUrl: string = `${import.meta.env.VITE_FETCH_URL}/jsons/quiz/${dynamicFetchPathUrl}`;

    // use APIを使うための下準備
    const fetchdataPromise: Promise<quizType[]> = fetch(fetchPathUrl).then(res => res.json());

    return (
        <>
            {selectQuiz.length > 0 &&
                <QuizComponent>
                    {/* Suspense 必須。fetchdataPromise が未完了なら */}
                    {/* Suspense の fallback が返される */}
                    <Suspense fallback={<Loading />}>
                        <QuizProgressBar fetchdataPromise={fetchdataPromise} />
                        <QuizContents fetchdataPromise={fetchdataPromise} />
                        <QuizBtn fetchdataPromise={fetchdataPromise} />
                        <ViewAnswers fetchdataPromise={fetchdataPromise} />
                    </Suspense>
                </QuizComponent>
            }
        </>
    );
    // ...中略
```

上記で用意した`fetchdataPromise`（クイズゲームの中身データ）を使用する各種コンポーネントで以下のように呼び出して使っています。

```tsx
export const QuizProgressBar = memo(({ fetchdataPromise }: { fetchdataPromise: Promise<quizType[]> }) => {
    // fetchdataPromiseを取得
    const getData: quizType[] = use(fetchdataPromise);

    const { questionCounter } = useContext(QuestionCounterContext);

    return (
        <>
            {questionCounter < getData.length &&
                /* 設問数分の進行バー・ポイントを生成 */
                <ProgressBar id="progressBar">
                    <ul>
                        {getData.map((_, i) => (
                        // 中略...
```

いままで`useEffect`とかを用いて行っていたフェッチ処理がとてもシンプルに行えるようになっていて、`React 19`では開発者体験がさらに良くなっていると感じました！

ありがとう！ `React`！

ところで、`fetchPathUrl`という変数にデータフェッチするためのパス文字列を入れています。
これは、`public`に`jsons`というフォルダを設けて、そこでクイズや回答に関するコンテンツデータを一元管理しているためです。

こうすることで疑似的なCMSのように、`jsons`内のクイズや回答に関するファイルを編集または新規追加するだけでクイズゲームのコンテンツデータを柔軟に取り回せます。

### 新しいコンテクストプロバイダ
あと今回、[新しい`Context`の書き方](https://ja.react.dev/blog/2024/12/05/react-19#context-as-a-provider)も試したかったのです。

```tsx
// ...中略
export const QuestionCounterContextFlagment = (props: defaultContext) => {
    /* 設問数のカウンター */
    const [questionCounter, setQuestionCounter] = useState<number>(0);

    return (
        // React 19 以前の書き方では QuestionCounterContext.Provider 
        <QuestionCounterContext value={
            {questionCounter, setQuestionCounter}
        }>
            {props.children}
        </QuestionCounterContext>
    );
}
```

### `useActionState`
完全に理解したわけではないですが使ってみて何となくは分かった（ような気がします）。

使うまでは旧称の`useFormState`という名称と[公式ドキュメント](https://ja.react.dev/reference/react/useActionState)の以下サンプルコード（`formAction`の部分）に引きずられて **`form`処理関連でしか使わないもの** という印象でした。

```jsx
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
```

しかし、実際に使ってみると**非同期処理をシンプルに実装できる汎用性の高いフック**という印象に変わりました。

筆者は今回、クイズゲームの結果表示に関するデータフェッチ処理で使用しました。
```tsx
const [, fetchAnswersDataAction, isPending] = useActionState(
    async () => {
        // ...中略

        /* urlPathPart： フェッチする回答結果JSONファイルのパス名 */
        /* 回答強制機能がオフの場合に得点数に応じて回答結果が変わる */
        let urlPathPart: string = 'answer-low.json';
        if (scorePointRef.current >= highScorePoint) {
            urlPathPart = 'answer-high.json';
        } else if (scorePointRef.current >= mediumScorePoint && scorePointRef.current < highScorePoint) {
            urlPathPart = 'answer-medium.json';
        }

        // フェッチ処理
        const fetchUrlPath: string = `${import.meta.env.VITE_FETCH_URL}/jsons/answers/${selectQuiz}/${urlPathPart}`;
        const res: Response = await fetch(fetchUrlPath);
        const resObj: answerResultType[] = await res.json();

        setFetchAnswersData(resObj);

        return null;  // return newState;
    },
    null // initialState
);

const withTransition_fetchAnswersDataAction: () => void = () => {
    // startTransition がないと（ action または Context の外部で dispatch したと）怒られる
    startTransition(() => {
        fetchAnswersDataAction();
    });
}
```

筆者はフェッチデータを`setFetchAnswersData`で状態管理しているので、今回の`useActionState`では初期値は`null`で、返却値も`null`にしています。
加えて、返り値の`state`部分も指定していません（※返却値が`null`なため）

::: note warn
正直ここら辺は`useActionState`を完全に理解し切れておらず、もっと適切な実装があるように感じます。
:::

今回使ってみて一つ興味深かったことは、先のコード内にも記載した **`startTransition`がないと（actionまたはContextの外部でdispatchしたと）怒られる** という部分についてです。

当該コードの部分は以下のように`startTransition`無しでも問題なく機能しました。

```diff
const withTransition_fetchAnswersDataAction: () => void = () => {
    // startTransition がないと（ action または Context の外部で dispatch したと）怒られる
-    startTransition(() => {
        fetchAnswersDataAction();
-    });
}
```

しかし、この場合は下記のようなエラーがログに出力されます。

::: note alert
An async function was passed to useActionState, but it was dispatched outside of an action context. This is likely not what you intended. Either pass the dispatch function to an `action` prop, or dispatch manually inside `startTransition`
:::

> useActionStateに非同期関数が渡されましたが、アクション・コンテキストの外でディスパッチされました。これはおそらく意図したものではありません。ディスパッチ関数を `action` プロップに渡すか、`startTransition` 内で手動でディスパッチしてください。

というわけで、`useActionState`のアクション実行時には`startTransition`を用いた方が無難だと思います。

理由は以下のページ情報が詳しいです。
> ここでstartTransitionを使う理由は、useActionStateが返した関数はトランジションの内部で呼ばなければならないからです。このようにuseActionStateが作ったアクション（今回はincrement）を使う際は、まずトランジションを開始して、その中でアクションを呼び出しましょう。

https://zenn.dev/uhyo/books/react-19-new/viewer/useactionstate

ちなみに、筆者はアクション（`withTransition_fetchAnswersDataAction`）を、以下のように別のコンポーネントに渡して使っています。

```tsx
<QuizBtn props={{
    getData: getData,
    scorePointRef: scorePointRef,
    withTransition_fetchAnswersDataAction: withTransition_fetchAnswersDataAction
}} />

// `button`の`onClick`イベントに withTransition_fetchAnswersDataAction を使用
```

このように子コンポーネントに`useActionState`で作ったアクションを渡して利用できるので柔軟性や汎用性も高いと感じました。

わがままをいえばカスタムフックのように、**`useActionState`で作ったステートやアクションだけを切り出して（`export`できて）** 自由に使えるともっと良いなとも感じましたが。

--- 

`use`API や`useActionState`しかり、`React 19`では非同期処理の実装がシンプルになっているように感じます。
この辺りはドキュメントや各種情報を追うよりも（筆者のように体で覚えるタイプは）自分で一度使ってみた方が分かりやすいかもしれません。

## デプロイ（ホスティング）先
今回、`Vercel`を選びました。使い慣れているのが主な理由ですが、今度は気になっている[`Cloudflare Pages`](https://www.cloudflare.com/ja-jp/developer-platform/products/pages/)を使ってみたいなぁと思ってます。

ちなみに、`Vercel`にデプロイしたのでデータフェッチのURLパスは環境変数に設定していますが、
国内ホスティングサービスなど環境変数の設定に対応していないホスティング先のケースにも対応できるようにしています。

これに関して気になる方は、当クイズゲームのリポジトリの [`README`内にある環境変数に対応していないホスティング先の場合](https://github.com/Benjuwan/quizgame?tab=readme-ov-file#%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%97%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E3%83%9B%E3%82%B9%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0%E5%85%88%E3%81%AE%E5%A0%B4%E5%90%88) を見ていただけますと幸いです。

## さいごに
ここまで読んでいただき、ありがとうございます。

社会も、世界も、暗い雰囲気で、
学生も、社会人も、褒められる機会が減っていると思うので、
このクイズゲームで是非、自己肯定感を爆上げしてください（こじつけ）

当クイズゲームは自由に使っていただいて結構です。
GitHubを置いておきますので、関心のある方はどうぞ。

https://github.com/Benjuwan/quizgame