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


## はじめに

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

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

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

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

## もくじ

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

## 非同期データ取得

今回は[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;
  }
};
```

<!-- TODO: util追加 -->

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

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

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

### ゲーム状態

Qwik では状態管理のために`createContextId`と`useContextProvider`を使用してコンテキストを作成し、`useStore`で状態を管理します。

```typescript:src/features/game/providers/index.tsx
import { GameContextType, GameStoreType } from "../types";
import { INIT_GAME_STATE } from "../constants";
import {
  createContextId,
  useStore,
  component$,
  Slot,
  useContextProvider,
  $,
} from "@builder.io/qwik";

export const GameContext = createContextId<GameContextType>("game.context");

export const GameProvider = component$(() => {
  const gameStore = useStore<GameStoreType>(INIT_GAME_STATE);

  const decrementLife = $(() => {
    const MIN_LIFE = 0;
    const updatedLife = gameStore.life - 1;
    const isGreaterThanMin = updatedLife >= MIN_LIFE;
    const life = isGreaterThanMin ? updatedLife : MIN_LIFE;

    gameStore.life = life;
    gameStore.round++;
  });

  const incrementScore = $(() => {
    gameStore.round++;
    gameStore.score++;
  });

  const startGame = $(() => {
    gameStore.state = "START";
  });

  const finishGame = $(() => {
    gameStore.state = "END";
  });

  const initGame = $(() => {
    gameStore.life = INIT_GAME_STATE.life;
    gameStore.round = INIT_GAME_STATE.round;
    gameStore.score = INIT_GAME_STATE.score;
    gameStore.state = INIT_GAME_STATE.state;
  });

  const handleAnswer = $((isCorrect: boolean) => {
    if (isCorrect) return incrementScore();

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

    return decrementLife();
  });

  useContextProvider(GameContext, {
    gameStore,
    initGame,
    finishGame,
    startGame,
    handleAnswer,
  });

  return <Slot />;
});
```

:::note info

- `createContextId`: 型付きのコンテキスト ID を作成します ([参考](https://qwik.dev/docs/components/context/#createcontextid))
- `useStore`: リアクティブな状態を作成します ([参考](https://qwik.dev/docs/components/state/#usestore))
- `useContextProvider`: コンテキストを提供します ([参考](https://qwik.dev/docs/components/context/#usecontextprovider))

:::

### タイマー状態

タイマー機能では`useSignal`と`useVisibleTask$`を組み合わせて実装します。

(参考: [useSignal](https://qwik.dev/docs/components/state/#usesignal) / [useVisibleTask$][https://qwik.dev/docs/components/tasks/#usevisibletask])

```typescript:src/features/time/providers/index.tsx
import {
  $,
  component$,
  createContextId,
  Slot,
  useContextProvider,
  useSignal,
  useVisibleTask$,
} from "@builder.io/qwik";

const MAX_TIME = 15;

export const TimeProvider = component$(() => {
  const timer = useSignal<number>(MAX_TIME);
  const isRunning = useSignal(false);

  useVisibleTask$(({ cleanup }) => {
    let intervalId: NodeJS.Timeout | undefined;

    if (isRunning.value) {
      intervalId = setInterval(() => {
        if (timer.value > -1) {
          timer.value--;
        } else {
          isRunning.value = false;
        }
      }, 1000);
    }

    cleanup(() => {
      if (intervalId) {
        clearInterval(intervalId);
      }
    });
  });

  // タイマー制御関数
  const startTimer = $(() => {
    timer.value = MAX_TIME;
    isRunning.value = true;
  });

  useContextProvider(TimeContext, {
    timer,
    startTimer,
    clearTimer,
    resetTimer,
    MAX_TIME,
  });

  return <Slot />;
});
```

## フォーム状態

```ts:features/form/hooks/index.ts
import { getMergedKanas } from "../../../utils/kana";
import { FormType, FormValues } from "../types";
import { $, useStore } from "@builder.io/qwik";

export const useForm = () => {
  const formStore = useStore<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,
    }));

    formStore.options = options;
    formStore.displayTexts = [];
  });

  const addDisplayText = $((displayText: FormType) => {
    const options = formStore.options.filter(
      (option) => JSON.stringify(option) !== JSON.stringify(displayText)
    );
    const displayTexts = [...formStore.displayTexts, displayText];

    formStore.options = options;
    formStore.displayTexts = displayTexts;
  });

  const removeDisplayText = $((displayText: FormType) => {
    const options = [...formStore.options, displayText];
    const displayTexts = formStore.displayTexts.filter(
      (display) => JSON.stringify(display) !== JSON.stringify(displayText)
    );

    formStore.options = options;
    formStore.displayTexts = displayTexts;
  });

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

## ストアの呼び出し

<!-- TODO: プレイ画面セクション追加 -->
<!-- TODO: リザルト画面セクション追加 -->

### ルート画面

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

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

(参考: [useResource$](https://qwik.dev/docs/components/state/#useresource))

**副作用**
`useTask$`を用いた依存値の変更を監視による副作用を追加します。
(参考: [useTask$](https://qwik.dev/docs/components/tasks/#usetask))

**Resource コンポーネント**
非同期データの読み込み状態に応じて適切な UI を表示する Resource コンポーネントを使用します。

参考: [Resource](https://qwik.dev/api/qwik/#resource)

```typescript:src/routes/index.tsx
import { $, component$, Resource, useResource$, useTask$ } from "@builder.io/qwik";

export default component$(() => {
const { gameStore, handleAnswer, initGame, finishGame } = useGame();
  const { timer, startTimer, resetTimer } = useTime();
  const { formStore, initOptions, addDisplayText, removeDisplayText } =
    useForm();

  const pokemonResource = useResource$(({ cleanup, track }) => {
    track(() => gameStore.round);
    track(() => gameStore.state);

    const controller = new AbortController();
    cleanup(() => controller.abort());

    return fetchPokemon();
  });

  const onAnswer = $(async () => {
    const pokemon = await pokemonResource.value;
    const joinedDisplayTexts = formStore.displayTexts
      .map(({ char }) => char)
      .join("");
    const isCorrect = joinedDisplayTexts === pokemon?.name;
    const correctType = isCorrect ? "✅ 正解" : "❌ 不正解";
    const message = `${correctType} \n 答えは「${pokemon?.name}」です`;

    alert(message);
    handleAnswer(isCorrect);
  });

  const onRetry = $(() => {
    initGame();
    resetTimer();
  });

  useTask$(async ({ track }) => {
    const pokemon = track(() => pokemonResource.value);
    const name = (await pokemon)?.name;

    initOptions(name || "");
    startTimer();
  });

  useTask$(({ track }) => {
    const time = track(() => timer.value);

    if (time === -1) {
      finishGame();
    }
  });

  if (gameStore.state === "END") {
    return <GameResultView onRetry$={onRetry} />;
  }

  return (
    <Resource
      value={pokemonResource}
      onPending={() => (
        <div class={styles.loader__container}>
          <Loader />
        </div>
      )}
      onResolved={(pokemon) =>
        pokemon && (
          <GamePlayView
            pokemon={pokemon}
            displayTexts={formStore.displayTexts}
            options={formStore.options}
            onAddInput$={addDisplayText}
            onAnswer$={onAnswer}
            onRemoveInput$={removeDisplayText}
          />
        )
      }
    />
  );
});
```

## 最終的なコード

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

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

## おわりに

Qwik の状態管理とデータ取得の基本的な実装を行いました。
`useStore`や`useResource$`などの API は直感的で使いやすく、
コンテキストを使った状態管理も比較的シンプルに実装できました。

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

---

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