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


:::note warn
記事データの反映がうまくいってなかったので再度公開しました。 2024/12/12
:::

## はじめに

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

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

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

## フロントエンドの世界 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-について)
- [もくじ](#もくじ)
- [前提](#前提)
- [Remix のデータフロー](#remix-のデータフロー)
- [loader](#loader)
- [action](#action)
- [Form](#form)
- [サーバーサイドでの状態管理](#サーバーサイドでの状態管理)
- [今回のコード](#今回のコード)
- [おわりに](#おわりに)

## 前提

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

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

## Remix のデータフロー

![loader-action-component.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/f6d5ae16-c877-5fc6-5f1b-9f20f09ae198.png)

Remix のデータフローはサーバーサイド・クライアントサイドを超えて真に一方向です。
サーバー (状態) からクライアント (ビュー) へ、そして再びサーバー (アクション)という流れです。

(参考: [Data Flow in Remix](https://remix.run/blog/remix-data-flow))

## loader

`Remix` の主なデータ取得方法は、各ルートに定義する `loader` 関数です。

`loader` はサーバーサイドで実行され、表示に必要なデータをページに返す事ができます。

(参考: [loader](https://remix.run/docs/ja/main/route/loader))

### リダイレクト処理

index ページではゲーム画面へのリダイレクトを担います

```tsx:routes/_index.tsx
import { LoaderFunction, redirect } from "@remix-run/cloudflare";

export const loader: LoaderFunction = async () => {
  return redirect("/game");
};
```

### ポケモンデータとライフの取得 (ゲーム画面)

`loader` でランダムなポケモンを取得し、残りライフと一緒にゲーム画面に返します。

```tsx:routes/game._index.tsx
import { fetchPokemon } from "~/features/pokemon/data"

export const loader: LoaderFunction = async ({ request }) => {
  const pokemon = await fetchPokemon();
  // 後ほど動的にライフを返すようにします。
  const life = 3

  // ランダムなポケモンと残りライフをGamePlayに返す
  return { pokemon, life };
};

export default function GamePlay() {
  ...
}
```

<details>
  <summary>👨‍💻 fetchPokemonの実装</summary>

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

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

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

const fetchPokemonSpecie = async (pokemonId: number) => {
try {
  const specie = await fetch(`${BASE_ENDPOINT}/pokemon-species/${pokemonId}`);
  const specieResponse: Awaited<SpecieResponse> = await specie.json();
  const specieNameJP = specieResponse.names.find(
    (specie) => specie.language.name === "ja-Hrkt"
  );

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

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

export const fetchPokemon = async () => {
  try {
    const totalPokemonCount = await fetchTotalPokemonCount();
    if (!totalPokemonCount) return null;

    const MIN_POKEMON_ID = 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;
  }
};
```

</details>

### ゲームスコアの取得 (リザルト画面)

`loader` でゲームスコアを取得し、リザルト画面に返します。

```tsx:routes/game.result.tsx
export const loader: LoaderFunction = async ({ request }) => {
  // 後ほど動的にスコアを返すようにします。
  const score = 1

  if (!score) return redirect("/game");

  // ゲームのスコアをGameResultに返す
  return { score };
};

export default function GameResult() {
  ...
}
```

### useLoaderData

ページコンポーネントから loader のデータを利用するには `useLoaderData` hook を使用します。

(参考: [useLoaderData](https://remix.run/docs/ja/main/hooks/use-loader-data#useloaderdata))

```diff_tsx:game._index.tsx
// ゲーム画面

+ type GamePlayLoaderResponse = {
+   pokemon: PokemonType;
+   life: number;
+ };

export const loader: LoaderFunction = async ({ request }) => {
  ...
  return { pokemon, life };
};

export default function GamePlay() {
+ const { pokemon, life } = useLoaderData<GamePlayLoaderResponse>();
  ...
}
```

```diff_tsx:game.result.tsx
// リザルト画面

+ type GameResultLoaderResponse = {
+   score: number;
+ };

export const loader: LoaderFunction = async ({ request }) => {
  ...
  return { score };
};

export default function GamePlay() {
+ const { score } = useLoaderData<GameResultLoaderResponse>();
  ...
}
```

## action

`Remix` ではデータ取得のみでなく、データの更新も行う事ができます。
その時に使用するのが `action` 関数です。

フォーム送信等によって`action` はサーバーサイドで実行され、
その後 `loader`が再度呼ばれる流れです。

(参考: [action](https://remix.run/docs/ja/main/route/action))

## Form

`Form`コンポーネント に action と method を渡し、フォーム送信を行うことで `action`関数の処理が実行されます。

(参考: [Form](https://remix.run/docs/ja/main/components/form#form))

```tsx:イメージ
// routes/events._index.tsx

import { Form } from "@remix-run/react";

export const action = () => {
  // submit時に処理が呼ばれる
  console.log("event")
}

function NewEvent() {
  return (
    <Form action="/events" method="post">
      <input name="title" type="text" />
      <input name="description" type="text" />
      <input type="submit" />
    </Form>
  );
}
```

### useSubmit

任意のタイミングでフォーム送信を行うには `useSubmit` hook を使用します。
こちらも `Form` と同じくフォーム送信後に `action` 関数が呼ばれます。

(参考: [useSubmit](https://remix.run/docs/ja/main/hooks/use-submit#usesubmit))

```diff_tsx:routes/game.result.tsx
// リザルト画面

export const action: ActionFunction = async ({ request }) => {
  if (request.method !== "POST") return null;

  // フォームが送信されたらゲーム画面へリダイレクトする
  return redirect("/game")
};

export default function GameResult() {
  ...
+ const submit = useSubmit();

+ const handleRetry = useCallback(
+   () => submit(new FormData(), { method: "post" }),
+   [submit]
+ );

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

※ ゲーム画面における useSubmit 部分のコードは割愛しますが、任意のタイミングで submit 関数を呼ぶことで action の処理が走る点は変わらないです。

## サーバーサイドでの状態管理

今回はせっかくサーバーサイドの Remix 処理を利用しているので、ゲームの状態管理も Cookie で行ってみます。

### クッキーの作成

ライフ・スコア管理用の Cookie を作成します。
細かい設定も可能ですが今回はミニマムで進めます。

(参考: [createCookie](https://remix.run/docs/en/main/utils/cookies#createcookie))

```tsx:app/features/game/cookies/life.server.ts
import { createCookie } from "@remix-run/node";

export const lifeCookie = createCookie("life");
```

```tsx:app/features/game/cookies/score.server.ts
import { createCookie } from "@remix-run/node";

export const scoreCookie = createCookie("score");
```

### クッキーの取得・操作

`cookie.parse()`でリクエストヘッダーから Cookie をパースして取得する事ができます。(参考: [cookie.parse()](https://remix.run/docs/en/main/utils/cookies#cookieserialize))

また `cookie.serialize()`でクッキーの発行を行う事ができ、オプションに `maxAge: -1` を渡す事で破棄も行えます。(参考: [cookie.serialize()](https://remix.run/docs/en/main/utils/cookies#cookieserialize))

```diff_tsx:routes/game.result.tsx
// リザルト画面

// クッキーファイルのimport
+ import { lifeCookie } from "~/features/game/cookies/life.server";
+ import { scoreCookie } from "~/features/game/cookies/score.server";

export const loader: LoaderFunction = async ({ request }) => {
// クッキーを読み取ってリザルト画面で表示
+ const cookie = request.headers.get("Cookie");
+ const score = await scoreCookie.parse(cookie);

  if (!score) return redirect("/game");

  return { score };
};

export const action: ActionFunction = async ({ request }) => {
  if (request.method !== "POST") return null;

  const headers = new Headers();

// ゲーム状態のクッキーを削除しゲーム状態をリセット
+ headers.append(
+   "Set-Cookie",
+   await scoreCookie.serialize(null, { maxAge: -1 })
+ );
+ headers.append(
+   "Set-Cookie",
+   await lifeCookie.serialize(null, { maxAge: -1 })
+ );

// クッキー削除後はゲーム画面へ遷移
+ return redirect("/game", {
+   headers,
+ });
};
```

```diff_tsx:routes/game._index.tsx
export const loader: LoaderFunction = async ({ request }) => {
+ const cookie = request.headers.get("Cookie");
+ const life = await lifeCookie.parse(cookie);
+
+ const formattedLife = Number.isInteger(parseInt(life)) ? parseInt(life) : 3;

  const pokemon = await fetchPokemon();

  return { pokemon, life: formattedLife };
};

export const action: ActionFunction = async ({ request }) => {
// リクエストヘッダーからクッキー取得
+  const cookie = request.headers.get("Cookie");

+  const body = await request.formData();

// submit時にページから受け取った結果
+  const game = body.get("game")?.toString() ?? "";
+  const isCorrect = body.get("isCorrect")?.toString() ?? "";

+  const score = (await scoreCookie.parse(cookie)) ?? 0;
+  const life = (await lifeCookie.parse(cookie)) ?? 3;

// 正誤判定を元にスコアを計算
+  const nextScore =
+    isCorrect === "true" ? parseInt(score) + 1 : parseInt(score);

// 正誤判定を元にライフを計算
+  const nextLife = isCorrect === "true" ? parseInt(life) : parseInt(life) - 1;

// 新しいクッキーの値を設定
+  const headers = new Headers();
+  headers.append("Set-Cookie", await scoreCookie.serialize(String(nextScore)));
+  headers.append("Set-Cookie", await lifeCookie.serialize(String(nextLife)));

// ライフ計算の結果が0の場合はゲームを終了しリザルト画面へ遷移
+  const nextPath = nextLife === 0 || game === "END" ? "/game/result" : "/";

   return redirect(nextPath, {
+    headers,
   });
};
```

## 今回のコード

https://github.com/nuintee/frontend-assort-2024

## おわりに

WEB 標準の技術を利用しつつサーバーサイドを利用したデータフローも簡単に書くことが出来たので、正直思っていたより入門はしやすそうです。

この機会に是非 Remix を触ってみてはいかがでしょうか？

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

---

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