Svelte(Kit)の世界: データ取得と状態管理 #4


## はじめに

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

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

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

## フロントエンドの世界 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)

## 前提

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

※ 本シリーズ自体初めての方は 1 記事目の[Svelte(Kit)の世界: Svelte とは? #1](https://qiita.com/nuinteedev/private/876520b742d59d7bfba0)から是非見ていただけると嬉しいです。

## もくじ

- [はじめに](#はじめに)
- [フロントエンドの世界 2024 について](#フロントエンドの世界-2024-について)
- [前提](#前提)
- [もくじ](#もくじ)
- [今回作るモノ](#今回作るモノ)
- [非同期データ取得](#非同期データ取得)
- [ストアによる状態管理](#ストアによる状態管理)
- [ストアの呼び出しとリアクティブ設定](#ストアの呼び出しとリアクティブ設定)
- [ゲームロジックの追加](#ゲームロジックの追加)
- [エラー / ローディング対応](#エラー--ローディング対応)
- [最終的なコード](#最終的なコード)
- [おわりに](#おわりに)

## 今回作るモノ

https://81ab05e0.frontend-assort-2024.pages.dev/

![complete.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/46a11eef-3451-2f05-9a75-2162a70c87c7.gif)

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

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

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

## 非同期データ取得

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

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

### レスポンスの型定義

まずはレスポンスの型を `features/pokemon/types` 配下に定義します。

```ts:features/pokemon/types/index.ts
export type SpecieResponse = {
  names: {
    language: {
      name: string;
    };
    name: string;
  }[];
};

export type PokemonResponse = {
  id: number;
  name: string;
  sprites: {
    other: {
      home: {
        front_default: string;
      };
    };
  };
};
```

### ポケモン詳細取得

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

```ts:features/pokemon/utils/fetch.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/utils/fetch.ts`配下に作成します。

```ts:features/pokemon/utils/fetch.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/utils/fetch.ts`配下に作成します。

```ts:features/pokemon/utils/fetch.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/utils/fetch.ts`配下に作成します。

```ts:features/pokemon/utils/fetch.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;
  }
};
```

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

### ゲーム状態

ゲームの進行状況を管理する`gameState`ストアを作成します。

(参考: [svelte/store](https://svelte.jp/docs/svelte-store))

```ts:stores/game.ts
import { get, writable } from "svelte/store";

type GameState = {
  state: "START" | "PAUSE" | "END";
  life: number;
  score: number;
  round: number;
};

// 初期のゲーム状態
export const INIT_GAME_STATE: GameState = {
  state: "START",
  life: 3,
  score: 0,
  round: 1,
};

export const gameState = writable<GameState>(INIT_GAME_STATE);

// ライフを-1する処理
const decrementLife = () => {
  gameState.update((prev) => {
    const MIN = 0;
    const updatedLife = prev.life - 1;
    const isGreaterThanMin = updatedLife >= MIN;

    return {
      ...prev,
      // ラウンドはいずれの場合も加算
      round: prev.round + 1,
      // ライフ-1が0を下回る場合は0を設定する
      life: isGreaterThanMin ? updatedLife : MIN,
    };
  });
};

// スコアを+1する処理
const incrementScore = () => {
  gameState.update((prev) => ({
    ...prev,
    // ラウンドはいずれの場合も加算
    round: prev.round + 1,
    score: prev.score + 1,
  }));
};

// ゲームを開始状態にする処理
export const startGame = () => {
  gameState.update((prev) => ({ ...prev, state: "START" }));
};

// ゲームを終了状態にする処理
export const finishGame = () => {
  gameState.update((prev) => ({ ...prev, state: "END" }));
};

// ゲームを初期化状態にする処理
export const initGame = () => {
  gameState.set(INIT_GAME_STATE);
};

// 回答結果によってゲーム状態を変更する処理
export const handleAnswer = (isCorrect: boolean) => {
  if (isCorrect) return incrementScore();

  const { life } = get(gameState);

  // ライフが0になったらゲームを終了する
  if (life - 1 === 0) {
    finishGame();
  }

  return decrementLife();
};
```

### タイマー状態

制限時間を管理する`timer`ストアを作成します。

自然なカウントダウン計算を行うために`tweened motion`と`lienar easing`を使用します。

(参考: [Tweens](https://learn.svelte.jp/tutorial/tweens))

```ts:stores/timer.ts
import { tweened } from "svelte/motion";
import { linear } from "svelte/easing";

export const MIN_TIME = 0;
export const MAX_TIME = 15;
export const SECOND_MS = 1000;

// 制限時間は15秒
const DURATION = SECOND_MS * MAX_TIME;

export const timer = tweened(MAX_TIME, {
  duration: DURATION,
  easing: linear,
});

// タイマーを開始
export const startTimer = () => {
  // NOTE: 20からマイナスするので0に設定
  return timer.set(MIN_TIME);
};

// タイマーを停止
export const clearTimer = () => {
  return timer.set(MAX_TIME, { duration: 0 });
};

```

### フォーム状態

回答の入力状態を管理する`form`ストアを作成します。

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

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

// フォームの型
export type FormState = {
  displayTexts: AnswerType[];
  answerOptions: AnswerType[];
};
```

**form ストアの作成**

```ts:stores/form.ts
import { writable } from "svelte/store";
import type { AnswerType, FormState } from "../types/form";

const INITIAL_FORM_STATE: FormState = {
  displayTexts: [], // 回答欄の入力内容
  answerOptions: [], // 選択肢の内容
};

export const formState = writable<FormState>(INITIAL_FORM_STATE);

// 回答欄への入力追加処理
export const addInput = (char: AnswerType) => {
  formState.update((prev) => ({
    ...prev,
    displayTexts: [...prev.displayTexts, char],
  }));
};

// 回答欄から入力を削除する処理
export const removeInput = (id: string) => {
  formState.update((prev) => ({
    ...prev,
    displayTexts: prev.displayTexts.filter((display) => display.id !== id),
  }));
};

// 選択肢をセットする処理
export const setAnswerOptions = (newOptions: AnswerType[]) => {
  formState.update((prev) => ({ ...prev, answerOptions: newOptions }));
};

// フォームの内容を初期化する処理
export const initForm = () => {
  formState.set(INITIAL_FORM_STATE);
};
```

## ストアの呼び出しとリアクティブ設定

先ほど別ファイルに定義したストアをインポートします。
また、`$:`のリアクティブ宣言でストアの値変化に同期したデータの加工処理を行います。

```diff_svelte:+page.svelte
<script lang="ts">
+  import { timer, startTimer, clearTimer, MAX_TIME } from "../  stores/timer";
+  import {
+    gameState,
+    handleAnswer,
+    finishGame,
+    initGame,
+  } from "../stores/game";
+  import {
+    addInput,
+    formState,
+    initForm,
+    removeInput,
+    setAnswerOptions,
+  } from "../stores/form";
+
+  // [リアクティブ宣言] - 回答入力した内容をリアクティブ宣言でアサイン
+  $: displayTexts = $formState.displayTexts;
+
+  // [リアクティブ宣言] - 回答入力された選択肢を非表示にする処理 (回答と選択肢の同期)
+  $: options = $formState.answerOptions.filter(
+    (option) =>
+      !displayTexts.some(
+        (display) => JSON.stringify(option) === JSON.stringify(display)
+      )
+  );
</script>
...

{#if $gameState.state === "START"}
  {#if pokemonData}
...
+    <!-- ライフをリアクティブに表示 -->
+    <div class="flex items-center gap-x-2 m-4">
+      {#each Array.from({ length: $gameState.life }).map(i)   => i) as  life}
+        <div out:fade class="text-xl">❤️</div>
+      {/each}
+    </div>
...
  {:else if ...}
{:else}
```

## ゲームロジックの追加

### ゲームの初期化

`gameState`をサブスクライブし、`state`が `START`の場合は以下の処理を行います。

- フォームストアの初期化
- 新規ポケモンデータの取得
- 回答選択肢の生成
- タイマーの初期化

```diff_svelte:+page.svelte
<script lang="ts">
+ import { onDestroy } from 'svelte';
...
+ let pokemonData: PokemonResponse | null = null; // 非同期で設定するポケモンデータ用の変数
+ let isLoadingPokemon = false; // ポケモンデータを取得中かどうかのフラグ
+ let error: string | undefined = ""; // エラーハンドリング用
...
+ const unsubscribeGameState = gameState.subscribe(async ({ state }) => {
+    if (state === "START") {
+      try {
+        // フォームストアの初期化
+        initForm();
+
+        isLoadingPokemon = true;
+        // 新規ポケモンデータの取得
+        const pokemonResponse = await fetchPokemon();
+
+        pokemonData = pokemonResponse;
+
+        const splittedPokemonNameByChar = pokemonResponse?.name.+split("");
+
+        const mergedChars = getMergedKanas+(splittedPokemonNameByChar ?? []);
+        const shuffledPokemonNameChars = mergedChars.sort(
+          () => 0.5 - Math.random()
+        );
+
+        // 回答選択肢の生成
+        setAnswerOptions(
+          shuffledPokemonNameChars.map((char, index) => ({
+            char,
+            index,
+            id: crypto.randomUUID(),
+          }))
+        );
+
+        // タイマーの初期化
+        startTimer();
+      } catch (err) {
+        if (err instanceof Error) {
+          error = err.message;
+        }
+      } finally {
+        isLoadingPokemon = false;
+      }
+    }
+  }, clearTimer);
...
+ // ストアのアンサブスクライブ
+ onDestroy(() => {
+   unsubscribeGameState()
+ })
</script>
```

### 回答の正誤判定

入力された回答 `displayTexts` と現在表示されているポケモンの名前を比較して正誤判定を行います。

```diff_svelte:+page.svelte
<script lang="ts">
...
+  const handleSubmit = () => {
+    const correctAnswer = pokemonData?.name || "-";
+
+    // オブジェクト配列型式のカナ文字回答を連結し短文にする
+    const joinedDisplayTexts = displayTexts.map(({ char }) => char).join("");
+    // 正誤判定
+    const isCorrect = joinedDisplayTexts === correctAnswer
+
+    // アラートで表示するメッセージの作成
+    const correctType = isCorrect ? "✅ 正解" : "❌ 不正解";
+    const message = `${correctType} \n 答えは「${correctAnswer}」です`;
+
+    alert(message);
+    // ゲーム進行状態を生後によって変更するストア更新関数 (stores/gameからimport)
+    handleAnswer(isCorrect);
+  };
...
</script>
```

### 制限時間監視

timer ストアの変更を監視し、time が 0 になったらゲームを終了するようにします。

(参考: [svelte/store](https://svelte.jp/docs/svelte-store))

```diff_svelte:+page.svelte
<script lang="ts">
...
+  const unsubscribeTimer = timer.subscribe((time) => {
+    if (time > 0) return;
+
+    // タイマーが0になったらゲーム終了
+    alert("制限時間オーバーです");
+    finishGame();
+  });

  // ストアのアンサブスクライブ
  onDestroy(() => {
+   unsubscribeTimer()
    unsubscribeGameState()
  })
</script>
```

### リザルト画面表示

ゲーム状態が`START`出なくなった場合の `else` でリザルト画面を表示します。

```diff_svelte:+page.svelte
{#if $gameState.state === "START"}
...
  {/if}
{:else}
<!-- リザルト画面表示 -->
  <section class="page--centered">
    <div class="result__container">
      <h2 class="text-center text-slate-500">スコア</h2>
      <p class="text-center text-2xl">
+        <!-- ゲームストアを参照してスコアを表示 -->
+        {$gameState.score}<span class="text-sm">点</span>
      </p>
+      <!-- initGameを呼び出してゲームの初期化を行う -->
+      <PrimaryButton onClick={initGame}>もう一度プレイする</PrimaryButton>
    </div>
  </section>
{/if}
```

## エラー / ローディング対応

`if`, `else if`, `else` でそれぞれデータ取得時、エラー時、ローディング時を表現します。

```diff_svelte:+page.svelte
{#if $gameState.state === "START"}
  {#if pokemonData}
...
+        <!-- ポケモンデータを取得中かどうか -->
+        {#if isLoadingPokemon // }
          <div class="page__spinner">
            <Spinner />
          </div>
+        {:else}
          <Card imgSrc={pokemonData.sprites.other.home. front_default} />
...
        {/if}
      </main>
+    <!-- ポケモンデータ取得でエラーが起きた場合 -->
+    {:else if error}
      <div class="page--centered">
        <h2>エラー</h2>
        <p>{error}</p>
      </div>
+    <!-- ポケモンデータのみ再取得中 -->
+    {:else}
      <div class="page__spinner">
        <Spinner />
      </div>
    {/if}
{:else}
...
```

## 最終的なコード

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

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

## おわりに

約 4 時間で `Svelte` の基礎理解から簡単なアプリ開発まで行う事が出来て感動しています。

`Svelte` の公式チュートリアルの豊富さと直感的な構文、ビルトイン機能様様です。

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

---

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