記事データの反映がうまくいってなかったので再度公開しました。 2024/12/12
はじめに
はじめまして、WEB フロントエンドエンジニアの nuintee です。
この度かねてより関心があった Remix に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。
Remix に少しでも興味のある方は、ぜひご覧ください。
フロントエンドの世界 2024 について
「フロントエンドの世界 2024」は普段 Next.js
を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit)
, Remix
,SolidJS
, Qwik(City)
の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。
もくじ
前提
- Remix の世界: UI 構築とトランジション #3までの章を完了していること
※ 本シリーズ自体初めての方は 1 記事目のRemix の世界: Remix とは? #1から是非見ていただけると嬉しいです。
Remix のデータフロー
Remix のデータフローはサーバーサイド・クライアントサイドを超えて真に一方向です。
サーバー (状態) からクライアント (ビュー) へ、そして再びサーバー (アクション)という流れです。
(参考: Data Flow in Remix)
loader
Remix
の主なデータ取得方法は、各ルートに定義する loader
関数です。
loader
はサーバーサイドで実行され、表示に必要なデータをページに返す事ができます。
(参考: loader)
リダイレクト処理
index ページではゲーム画面へのリダイレクトを担います
import { LoaderFunction, redirect } from "@remix-run/cloudflare";
export const loader: LoaderFunction = async () => {
return redirect("/game");
};
ポケモンデータとライフの取得 (ゲーム画面)
loader
でランダムなポケモンを取得し、残りライフと一緒にゲーム画面に返します。
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の実装
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
でゲームスコアを取得し、リザルト画面に返します。
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)
// ゲーム画面
+ type GamePlayLoaderResponse = {
+ pokemon: PokemonType;
+ life: number;
+ };
export const loader: LoaderFunction = async ({ request }) => {
...
return { pokemon, life };
};
export default function GamePlay() {
+ const { pokemon, life } = useLoaderData<GamePlayLoaderResponse>();
...
}
// リザルト画面
+ 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)
// リザルト画面
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)
import { createCookie } from "@remix-run/node";
export const lifeCookie = createCookie("life");
import { createCookie } from "@remix-run/node";
export const scoreCookie = createCookie("score");
クッキーの取得・操作
cookie.parse()
でリクエストヘッダーから Cookie をパースして取得する事ができます。(参考: cookie.parse())
また cookie.serialize()
でクッキーの発行を行う事ができ、オプションに maxAge: -1
を渡す事で破棄も行えます。(参考: cookie.serialize())
// リザルト画面
// クッキーファイルの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,
+ });
};
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