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 11

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

Last updated at Posted at 2024-12-10

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

はじめに

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

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

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

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

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

frontend-assort-2024-banner.png

もくじ

前提

※ 本シリーズ自体初めての方は 1 記事目のRemix の世界: Remix とは? #1から是非見ていただけると嬉しいです。

Remix のデータフロー

loader-action-component.png

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

(参考: Data Flow in Remix)

loader

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

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

(参考: loader)

リダイレクト処理

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

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

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

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

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

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() {
  ...
}
👨‍💻 fetchPokemonの実装
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;
  }
};

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

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

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)

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>();
  ...
}
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)

Form

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

(参考: Form)

イメージ
// 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)

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)

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

export const lifeCookie = createCookie("life");
app/features/game/cookies/score.server.ts
import { createCookie } from "@remix-run/node";

export const scoreCookie = createCookie("score");

クッキーの取得・操作

cookie.parse()でリクエストヘッダーから Cookie をパースして取得する事ができます。(参考: cookie.parse())

また cookie.serialize()でクッキーの発行を行う事ができ、オプションに maxAge: -1 を渡す事で破棄も行えます。(参考: cookie.serialize())

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

今回のコード

おわりに

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

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

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


この記事は フロントエンドの世界 Advent Calendar 2024の 11 記事目です。
次の記事はこちら Remix の世界: ビルドとデプロイ #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?