SolidJSの世界: データ取得と状態管理 #4


## はじめに

はじめまして、WEB フロントエンドエンジニアの nuintee です。

この度かねてより関心があった SolidJS に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。

SolidJS に少しでも興味のある方は、ぜひご覧ください。

## フロントエンドの世界 2024 について

「[フロントエンドの世界 2024](https://qiita.com/advent-calendar/2024/frontend-assort-2024)」は普段 [`Next.js`](https://nextjs.org/) を書いている筆者が、同じフロントエンドライブラリである [`Svelte(Kit)`](https://svelte.jp/docs/introduction), [`Remix`](https://remix.run/docs/en/main) ,[`SolidJS`](https://docs.solidjs.com/), [`Qwik(City)`](https://qwik.dev/)の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。

![frontend-assort-2024-banner.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/7666d209-1aef-9ce5-c298-d8e60847e830.png)

## 前提

- [SolidJS の世界: UI 構築とトランジション #3](https://qiita.com/nuinteedev/private/38909bb77ee6bb719854)までの章を完了していること

※ 本シリーズが初めての方は、1 記事目の[SolidJS の世界: SolidJS とは？ #1](https://qiita.com/nuinteedev/private/ba0b6c49d448fdd3c0ea)からぜひ見ていただけると嬉しいです。

## もくじ

- [はじめに](#はじめに)
- [フロントエンドの世界 2024 について](#フロントエンドの世界-2024-について)
- [前提](#前提)
- [もくじ](#もくじ)
- [今回作るモノ](#今回作るモノ)
- [非同期データ取得](#非同期データ取得)
- [ストアによる状態管理](#ストアによる状態管理)
- [ストアの呼び出し](#ストアの呼び出し)
- [最終的なコード](#最終的なコード)
- [おわりに](#おわりに)

## 今回作るモノ

https://c78115e9.frontend-assort-2024-solidjs.pages.dev

![スクリーンショット 2024-12-16 0.16.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/f147553c-f2ef-6324-e970-4927bb2d17d1.png)

今回は [PokeAPI](https://pokeapi.co/) を用いたポケモン当てクイズアプリを`Svelte` で開発します。

機能的には以下の通りです。

- ランダム出題機能
- 回答判定機能
- ライフ管理
- 制限時間管理
- リザルト機能

## 非同期データ取得

今回は[PokeAPI](https://pokeapi.co/)を利用してポケモンのデータを取得します。

`features` 配下に `pokemon` という機能単位でディレクトリを作成し、そこに諸々追加していきます。

### ポケモン詳細取得

ポケモンの詳細情報を取得するために、`fetchPokemonDetail` 関数を`features/pokemon/data/index.ts`配下に作成します。

```ts:features/pokemon/data/index.ts
import type { PokemonResponse, SpecieResponse } from "../types";

const BASE_ENDPOINT = "https://pokeapi.co/api/v2";

const fetchPokemonDetail = async (
  pokemonId: number
): Promise<PokemonResponse | null> => {
  try {
    const pokemon = await fetch(`${BASE_ENDPOINT}/pokemon/${pokemonId}`);
    const pokemonResponse = await pokemon.json();
    return pokemonResponse;
  } catch (error) {
    return null;
  }
};
```

### 日本語版ポケモン名取得

ポケモンの日本語名を取得するために、`fetchPokemonSpecie` 関数を`features/pokemon/data/index.ts`配下に作成します。

```ts:features/pokemon/data/index.ts
...
const fetchPokemonSpecie = async (pokemonId: number) => {
  try {
    const specie = await fetch(`${BASE_ENDPOINT}/pokemon-species/${pokemonId}`);
    const specieResponse: Awaited<SpecieResponse> = await specie.json();

    // レスポンスのnamesから日本語名を取得
    const specieNameJP = specieResponse.names.find(
      (specie) => specie.language.name === "ja-Hrkt"
    );

    return {
      ...specieResponse,
      name: specieNameJP?.name ?? "",
    };
  } catch (error) {
    return null;
  }
};
```

### ポケモン総数の取得

ポケモン総数を取得するために、`fetchTotalPokemonCount` 関数を`features/pokemon/data/index.ts`配下に作成します。

```ts:features/pokemon/data/index.ts
...
const fetchTotalPokemonCount = async () => {
  try {
    const allSpecies = await fetch(`${BASE_ENDPOINT}/pokemon-species?limit=0`);
    const allSpeciesResponse = await allSpecies.json();

    const totalPokemonCount: number = allSpeciesResponse.count;

    return totalPokemonCount;
  } catch (error) {
    return null;
  }
};
```

### ランダムポケモン取得

ランダムなポケモンを取得するために、`fetchPokemon` 関数を`features/pokemon/data/index.ts` 配下に作成します。

```ts:features/pokemon/data/index.ts
...
export const fetchPokemon = async () => {
  try {
    // 総数取得
    const totalPokemonCount = await fetchTotalPokemonCount();
    if (!totalPokemonCount) return null;

    // ポケモンIDが1から始まるので最小値に設定
    const MIN_POKEMON_ID = 1;

    // 1と総数の間でランダムな整数を返す
    const randomId = Math.floor(
      Math.random() * (totalPokemonCount - MIN_POKEMON_ID) + MIN_POKEMON_ID
    );

    // ポケモン詳細データの取得
    const pokemonDetailResponse = await fetchPokemonDetail(randomId);
    if (!pokemonDetailResponse) return null;

    // 日本語名が含まれているポケモン種族データの取得
    const pokemonSpecieResponse = await fetchPokemonSpecie(
      pokemonDetailResponse.id
    );
    if (!pokemonSpecieResponse) return null;

    return {
      ...pokemonDetailResponse,
      ...pokemonSpecieResponse,
    };
  } catch (error) {
    return null;
  }
};
```

## ストアによる状態管理

今回は以下の 3 つの状態を管理します：

1. ゲーム状態（スコア、ライフ、ラウンド数など）
2. タイマー状態（制限時間）
3. フォーム状態（入力された文字）

### ゲーム状態

ゲームの進行状況（スコア、ライフ、ラウンド数）を管理し、正誤判定やゲームオーバー判定を行うために`GameContext`プロバイダーを作成します。

**型定義**

```ts:features/game/types/index.ts
...
export type GameContextType = {
  gameStore: GameStoreType;
  initGame: () => void;
  startGame: () => void;
  finishGame: () => void;
  handleAnswer: (isCorrect: boolean, onNextRound: () => void) => void;
};
```

**GameProvider**
`createContext`でコンテクストを作成した後、`createStore` で定義したオブジェクト型のストアを Provider で提供します。 (参考: [CreateContext](https://docs.solidjs.com/reference/component-apis/create-context) / [createStore](https://docs.solidjs.com/reference/store-utilities/create-store))

```ts:features/game/providers/index.ts
import { batch, createContext } from "solid-js";
import { createStore } from "solid-js/store";
import { GameContextType, GameStoreType } from "../types";
import { INIT_GAME_STATE } from "../constants";

// コンテキストを定義
export const GameContext = createContext<GameContextType>();

export const GameProvider = (props) => {
  const [gameStore, setGameStore] = createStore<GameStoreType>({
    life: INIT_GAME_STATE.life,
    round: INIT_GAME_STATE.round,
    score: INIT_GAME_STATE.score,
    state: INIT_GAME_STATE.state,
  });

  // ゲームライフのデクリメント関数
  const decrementLife = () => {
    setGameStore((prev) => {
      const MIN_LIFE = 0;
      const updatedLife = prev.life - 1;
      const isGreaterThanMin = updatedLife >= MIN_LIFE;

      const life = isGreaterThanMin ? updatedLife : MIN_LIFE;

      return {
        round: prev.round + 1,
        life,
      };
    });
  };

  // ゲームスコアのインクリメント関数
  const incrementScore = () => {
    setGameStore((prev) => ({
      round: prev.round + 1,
      score: prev.score + 1,
    }));
  };

  // ゲームを開始状態にする関数
  const startGame = () => {
    setGameStore("state", "START");
  };

  // ゲームを終了状態にする関数
  const finishGame = () => {
    setGameStore("state", "END");
  };

  // ゲームを初期状態にリセットする関数
  const initGame = () => {
    setGameStore(() => ({
      life: INIT_GAME_STATE.life,
      round: INIT_GAME_STATE.round,
      score: INIT_GAME_STATE.score,
      state: INIT_GAME_STATE.state,
    }));
  };

  // 正誤判定を元にゲーム状態を更新する関数
  const handleAnswer = (isCorrect: boolean, onNextRound: () => void) => {
    onNextRound();

    if (isCorrect) return incrementScore();

    if (gameStore.life - 1 === 0) {
      return finishGame();
    }

    return decrementLife();
  };

  // Childrenを受け取るProviderでストアを提供
  return (
    <GameContext.Provider
      value={{
        gameStore,
        initGame,
        startGame,
        finishGame,
        handleAnswer,
      }}
    >
      {props.children}
    </GameContext.Provider>
  );
};
```

:::note warn
SolidJS ではリアクティビティを担保するために分割代入は使用しないので要注意です。
(参考: [リアクティビティ](https://www.solidjs.com/guides/reactivity#considerations:~:text=%E3%81%AB%E3%81%AA%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82-,%E5%88%B6%E5%BE%A1,-%E3%83%95%E3%83%AD%E3%83%BC%E3%81%8B%E3%82%89%E3%81%AE))
:::

**hooks**
`GameContext`プロバイダーを利用するための hooks を定義します。

```ts:features/game/hooks/index.ts
import { useContext } from "solid-js";
import { GameContext } from "../providers";

export const useGame = () => {
  return useContext(GameContext);
};
```

### タイマー状態

アプリケーション全体で制限時間のカウントダウンを管理し、時間切れ時の処理を制御する `Timer` プロバイダーを作成します。

**型ファイルの追加**

```ts:features/time/type/index.ts
import { Accessor } from "solid-js";

export type TimeStoreType = number;

export type TimeContextType = {
  timer: Accessor<number>;
  resetTimer: () => void;
  startTimer: () => void;
  MAX_TIME: number;
};
```

```ts:features/time/providers/index.ts
import {
  Accessor,
  createContext,
  createEffect,
  createSignal,
  onCleanup,
} from "solid-js";
import { TimeContextType, TimeStoreType } from "../types";

// 制限時間
const MAX_TIME = 15;

// コンテキストを定義
export const TimeContext = createContext<TimeContextType>();

export const TimeProvider = (props) => {
  const [timer, setTimer] = createSignal<TimeStoreType>(MAX_TIME);

  let interval: NodeJS.Timeout | null = null;

  // カウントダウンを開始する関数
  const startTimer = () => {
    clearTimer();
    interval = setInterval(() => setTimer((prev) => prev - 1), 1000);
  };

  // タイマーをストップする関数
  const clearTimer = () => {
    if (interval) {
      clearInterval(interval);
      interval = null;
    }
  };

　// タイマーを初期値にリセットする関数
  const resetTimer = () => {
    clearTimer();
    setTimer(MAX_TIME);
  };

  // カウントダウンが-1になった場合にタイマーを止める処理
  // NOTE: 1000msのdelayを入れているので -1しています。
  createEffect(() => {
    if (timer() === -1) {
      clearTimer();
    }
  });

  // コンポーネントが破棄された時にタイマーを止める処理
  onCleanup(clearTimer);

  return (
    <TimeContext.Provider
      value={{
        timer,
        resetTimer,
        startTimer,
        MAX_TIME,
      }}
    >
      {props.children}
    </TimeContext.Provider>
  );
};
```

### フォーム状態

ユーザーの入力状態を管理し、文字の追加・削除を制御する `form`ストアを作成します。

**型ファイルの追加**

```ts:types/form/types/index.ts
// 回答の型
export type FormType = {
  char: string;
  index: number;
};

export type FormValues = {
  options: FormType[];
  displayTexts: FormType[];
};
```

**utils の作成**

```ts:utils/kana/index.ts
// カタカナを生成する関数
const generateKatakanaArray = () => {
  const startCode = 0x30a1; // 「ァ」から
  const endCode = 0x30fa; // 「ヺ」まで
  const katakanaArray = [];

  for (let code = startCode; code <= endCode; code++) {
    katakanaArray.push(String.fromCharCode(code));
  }

  return katakanaArray;
};

// 解答を元にランダムな選択肢を生成する関数
export const getMergedKanas = (chars: string[]) => {
  const notIncludedKanas = generateKatakanaArray().filter(
    (kana) => !chars.includes(kana)
  );
  const shuffledKanas = notIncludedKanas.sort(() => 0.5 - Math.random());
  const randomPickedKanas = shuffledKanas.slice(0, 3);
  return [...randomPickedKanas, ...chars];
};
```

**form ストアの作成**

```ts:features/form/hooks/index.ts
import { createStore } from "solid-js/store";
import { getMergedKanas } from "../../utils/kana";
import { FormType, FormValues } from "../types";

export const useForm = () => {
  const [formStore, setFormStore] = createStore<FormValues>({
    options: [],
    displayTexts: [],
  });

  // 回答の選択肢を設定する関数
  const initOptions = (answer: string) => {
    const splittedAnswer = answer.split("");

    const kanasWithDummy = getMergedKanas(splittedAnswer);

    const shuffledAnswerChars = kanasWithDummy.sort(() => 0.5 - Math.random());

    const options = shuffledAnswerChars?.map((char, index) => ({
      char,
      index,
    }));

    setFormStore(() => ({
      options,
      displayTexts: [],
    }));
  };

  // 入力を回答欄に追加する関数
  const addDisplayText = (displayText: FormType) => {
    setFormStore((prev) => {
      const options = prev.options.filter(
        (option) => JSON.stringify(option) !== JSON.stringify(displayText)
      );
      const displayTexts = [...prev.displayTexts, displayText];

      return {
        options,
        displayTexts,
      };
    });
  };

  // 回答欄から入力を削除する関数
  const removeDisplayText = (displayText: FormType) => {
    setFormStore((prev) => {
      const options = [...prev.options, displayText];
      const displayTexts = prev.displayTexts.filter(
        (display) => JSON.stringify(display) !== JSON.stringify(displayText)
      );

      return {
        options,
        displayTexts,
      };
    });
  };

  return {
    formStore,
    initOptions,
    addDisplayText,
    removeDisplayText,
  };
};
```

## ストアの呼び出し

### プレイ画面

ゲーム進行中の画面を実装します。ポケモンの画像表示、文字入力、タイマー表示などのコンポーネントを組み合わせます。

```diff_tsx:views/GamePlayView/index.tsx
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";
import { ProgressBar } from "../../features/ui/ProgressBar";
import { LifeCounter } from "../../features/ui/LifeCounter";
import { Card } from "../../features/pokemon/ui/Card";
import { CharButton } from "../../features/ui/CharButton";
import { PrimaryButton } from "../../features/ui/PrimaryButton";
+ import { useGame } from "../../features/game/hooks";
+ import { useTime } from "../../features/time/hooks";

export const GamePlayView = (props: GamePlayViewProps) => {
+ const { gameStore } = useGame();
+ const { timer, MAX_TIME } = useTime();

  const displayTexts = () => props.displayTexts;
  const options = () => props.options;
  const pokemon = () => props.pokemon;

  return (
    <section class="flex flex-col items-center justify-center">
      {/* timerとMAX_TIMEをタイマーに渡す */}
+     <ProgressBar value={timer()} maxValue={MAX_TIME} />

      {/* 残りライフをカウンターに渡す*/}
      <LifeCounter lifeCount={gameStore.life} />

      <h2 class="flex items-center justify-center text-2xl mt-8">
        このポケモンは誰？
      </h2>
      <Card src={pokemon()?.sprites.other.home.front_default ?? ""} />
      <div class="flex items-center gap-x-4 h-[70px]">
        <Show
          when={!!displayTexts().length}
          fallback={
            <span class="flex items-center justify-center text-gray-400">
              👇 クリックで名前を入力して下さい
            </span>
          }
        >
          <span>名前は</span>
          <For each={displayTexts()}>
            {(displayText) => (
              <CharButton onClick={() => props.onRemoveInput(displayText)}>
                {displayText.char}
              </CharButton>
            )}
          </For>
          <span>です</span>
        </Show>
      </div>
      <hr class="w-full" />
      <div class="flex items-center gap-x-4 h-[70px] m-2">
        <For each={options()}>
          {(option) => (
            <CharButton onClick={() => props.onAddInput(option)}>
              {option.char}
            </CharButton>
          )}
        </For>
      </div>
      {/* onAnswerを渡す */}
+     <PrimaryButton onClick={props.onAnswer}>回答する</PrimaryButton>
    </section>
  );
};
```

### リザルト画面

ゲーム終了時のスコア表示画面を実装します。最終スコアの表示とリトライボタンを配置します。

```diff_tsx:views/GameResultView/index.tsx
import { useGame } from "../../features/game/hooks";
import { PrimaryButton } from "../../features/ui/PrimaryButton";
import { GameResultViewProps } from "./type";

export const GameResultView = ({ onRetry }: GameResultViewProps) => {
+ const { gameStore } = useGame();

  return (
    <section class="h-full w-full flex items-center justify-center">
      <div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
        <h2 class="text-center text-slate-500">スコア</h2>
        <p class="text-center text-2xl">
+         {gameStore.score}
          <span class="text-sm">点</span>
        </p>
        <PrimaryButton onClick={onRetry}>もう一度プレイする</PrimaryButton>
      </div>
    </section>
  );
};
```

### ルート画面

アプリケーションのメインコンポーネントを実装します。ゲームの状態に応じて表示する画面を切り替えます。

**データ取得**
`createResource` を用いて非同期データを取得します。
`fetchPokemon` は本記事の上部で定義したフェッチャー関数です

(参考: [createResource](https://docs.solidjs.com/reference/basic-reactivity/create-resource#createresource))

```ts:該当箇所
const [pokemon, { refetch }] = createResource(fetchPokemon);
```

**createEffect**
ストアを用いたイベントハンドラーの定義や `createEffect` を用いたゲーム状態の制御処理を追加します。 (参考: [createEffect](https://docs.solidjs.com/reference/basic-reactivity/create-effect#createeffect))

**batch 更新**
ちなみに`batch` で囲っている箇所は一度の更新としてまとめられるので、複数回状態が再計算されることは無くなります。

(参考: [batch](https://docs.solidjs.com/reference/reactive-utilities/batch))

```diff_tsx:views/App.tsx
import {
+ createEffect,
+ createResource,
  ErrorBoundary,
  Match,
  Switch,
  type Component,
  batch,
} from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { fetchPokemon } from "./features/pokemon/data";
+ import { useForm } from "./features/form/hooks";
+ import { useGame } from "./features/game/hooks";
+ import { useTime } from "./features/time/hooks";

+ import Logo from "../public/logo.svg";

const App: Component = () => {
  // 非同期データの取得
+ const [pokemon, { refetch }] = createResource(fetchPokemon);
+ const { formStore, initOptions, addDisplayText, removeDisplayText } =
+   useForm();

+  const { initGame, finishGame, handleAnswer, gameStore } = useGame();
+  const { timer, startTimer, resetTimer } = useTime();

// リトライ時のハンドラー
+ const onRetry = () => {
+   batch(() => {
+     initGame();
+     resetTimer();
+     refetch();
+   });
+ };

// 回答時のハンドラー
+ const onAnswer = () => {
+   const joinedDisplayTexts = formStore.displayTexts
+     .map(({ char }) => char)
+     .join("");
+   const isCorrect = joinedDisplayTexts === pokemon().name;
+   const correctType = isCorrect ? "✅ 正解" : "❌ 不正解";
+   const correctAnswer = pokemon().name;
+   const message = `${correctType} \n 答えは「${correctAnswer}」です`;
+    alert(message);
+    handleAnswer(isCorrect, () => {
+      resetTimer();
+      refetch();
+    });
+  };

+  createEffect(() => {
+    if (pokemon.state !== "ready") return;
+
+    // ゲームの初期化
+    if (gameStore.state === "START") {
+      initOptions(pokemon().name);
+      startTimer();
+    }
+  });
+
+  createEffect(() => {
+    // アニメーションの1000msのdelayを追加しているので-1で終了判定
+    if (timer() === -1) {
+      batch(() => {
+        finishGame();
+        resetTimer();
+      });
+    }
+  });

  return (
    <ErrorBoundary fallback={<p>Error</p>}>
      <Switch>
        <Match when={pokemon.loading}>
          <div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
            <Loader />
          </div>
        </Match>
        <Match when={gameStore.state === "END"}>
+         <GameResultView onRetry={onRetry} />
        </Match>
        <Match when={gameStore.state === "START"}>
          <GamePlayView
            pokemon={pokemon()}
            displayTexts={formStore.displayTexts}
            options={formStore.options}
+           onAddInput={addDisplayText}
+           onRemoveInput={removeDisplayText}
+           onAnswer={onAnswer}
          />
        </Match>
      </Switch>
      {/* フレームワークロゴ */}
+     <div class="absolute bottom-4 right-4">
+       <img class="w-28" src={Logo} alt="SolidJS" draggable={false} />
+     </div>
    </ErrorBoundary>
  );
};

export default App;
```

## 最終的なコード

https://github.com/nuintee/frontend-assort-2024/tree/main/apps/svelte-client

※ 記事執筆前に実装したので所々記事と異なるコードが存在します。ご了承ください。

## おわりに

SolidJS では `For`, `Switch` / `Match` のような組み込みの便利なコンポーネント・hooks もあり、か着心地は良かったものの、リアクティビティ・副作用周りの挙動がなかなか掴めず少し苦戦しました。

とはいえ非常に軽くてスマートな記述が行えるので、もし使いこなせたら少ないコード量で速いアプリケーションを開発できそうだなと思いました。

また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします！

---

この記事は [フロントエンドの世界 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/frontend-assort-2024)の 16 記事目です。
次の記事はこちら [SolidJS の世界: ビルドとデプロイ #5](https://qiita.com/nuinteedev/private/727a7ab11dbdf4636acf)