0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンドの世界Advent Calendar 2024

Day 21

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

Last updated at Posted at 2024-12-20

はじめに

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

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

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

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

フロントエンドの世界 2024」は普段 Next.js を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit), Remix ,SolidJS, Qwik(City)の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。

frontend-assort-2024-banner.png

もくじ

非同期データ取得

今回はPokeAPIを利用してポケモンのデータを取得します。

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

ポケモン詳細取得

ポケモンの詳細情報を取得するために、fetchPokemonDetail 関数をfeatures/pokemon/data/index.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配下に作成します。

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配下に作成します。

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 配下に作成します。

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. フォーム状態(入力された文字)

ゲーム状態

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

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 />;
});
  • createContextId: 型付きのコンテキスト ID を作成します (参考)
  • useStore: リアクティブな状態を作成します (参考)
  • useContextProvider: コンテキストを提供します (参考)

タイマー状態

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

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

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 />;
});

フォーム状態

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,
  };
};

ストアの呼び出し

ルート画面

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

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

(参考: useResource$)

副作用
useTask$を用いた依存値の変更を監視による副作用を追加します。
(参考: useTask$)

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

参考: Resource

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}
          />
        )
      }
    />
  );
});

最終的なコード

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

おわりに

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

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


この記事は フロントエンドの世界 Advent Calendar 2024の 21 記事目です。
次の記事はこちら Qwik(City)の世界: ビルドとデプロイ #5

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?