Open35

React Router v7 (Remix) で遊ぶ

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロジェクト作成

コマンド
cd ~/workspace
npx create-react-router@latest my-react-router-app
cd my-react-router-app
npm i
npm run dev
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Route module

routes.ts で参照されるファイルのこと、ルートの振る舞いを定義する。

loader, action, コンポーネントをエクスポートする。

コード例
// provides type safety/inference
import type { Route } from "./+types/team";

// provides `loaderData` to the component
export async function loader({ params }: Route.LoaderArgs) {
  let team = await fetchTeam(params.teamId);
  return { name: team.name };
}

// renders after the loader is done
export default function Component({
  loaderData,
}: Route.ComponentProps) {
  return <h1>{loaderData.name}</h1>;
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Nested routes

コード例
import {
  type RouteConfig,
  route,
  index,
} from "@react-router/dev/routes";

export default [
  // parent route
  route("dashboard", "./dashboard.tsx", [
    // child routes
    index("./home.tsx"),
    route("settings", "./settings.tsx"),
  ]),
] satisfies RouteConfig;

親のパスは子にも含まれるようだ、なので下記 2 つのパスが出来上がる。

  • /dashboard
  • /dashboard/settings

また、dashboard.tsx にある Outlet コンポーネントに描画されるようになるようだ。

なんかレイアウトっぽい感じだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Layout routes

https://reactrouter.com/start/framework/routing#layout-routes

コード例
import {
  type RouteConfig,
  route,
  layout,
  index,
  prefix,
} from "@react-router/dev/routes";

export default [
  layout("./marketing/layout.tsx", [
    index("./marketing/home.tsx"),
    route("contact", "./marketing/contact.tsx"),
  ]),
  ...prefix("projects", [
    index("./projects/home.tsx"),
    layout("./projects/project-layout.tsx", [
      route(":pid", "./projects/project.tsx"),
      route(":pid/edit", "./projects/edit-project.tsx"),
    ]),
  ]),
] satisfies RouteConfig;

Nested routes に似ているが、パスが追加されないのが特徴のようだ。

なので引数もルートモジュールのみの 1 つになっている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Index Routes

https://reactrouter.com/start/framework/routing#index-routes

コード例
import {
  type RouteConfig,
  route,
  index,
} from "@react-router/dev/routes";

export default [
  // renders into the root.tsx Outlet at /
  index("./home.tsx"),
  route("dashboard", "./dashboard.tsx", [
    // renders into the dashboard.tsx Outlet at /dashboard
    index("./dashboard-home.tsx"),
    route("settings", "./dashboard-settings.tsx"),
  ]),
] satisfies RouteConfig;

これもわかりやすい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Route Prefixes

https://reactrouter.com/start/framework/routing#route-prefixes

コード例
import {
  type RouteConfig,
  route,
  layout,
  index,
  prefix,
} from "@react-router/dev/routes";

export default [
  layout("./marketing/layout.tsx", [
    index("./marketing/home.tsx"),
    route("contact", "./marketing/contact.tsx"),
  ]),
  ...prefix("projects", [
    index("./projects/home.tsx"),
    layout("./projects/project-layout.tsx", [
      route(":pid", "./projects/project.tsx"),
      route(":pid/edit", "./projects/edit-project.tsx"),
    ]),
  ]),
] satisfies RouteConfig;

これもわかりやすい、ただ単にプレフィックスを追加するだけのようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Dynamic Segments

https://reactrouter.com/start/framework/routing#dynamic-segments

app/routes.ts
route("teams/:teamId", "./team.tsx"),
app/team.tsx
import type { Route } from "./+types/team";

export async function loader({ params }: Route.LoaderArgs) {
  //                           ^? { teamId: string }
}

export default function Component({
  params,
}: Route.ComponentProps) {
  params.teamId;
  //        ^ string
}

ちなみにこれはコード補完が効くのかな? → 試してみたら効いた。

どうやら .react-router というディレクトリに情報が格納されているようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Component Routes

https://reactrouter.com/start/framework/routing#component-routes

プロンプト
Remix の Component Routes について教えてください。

https://chatgpt.com/share/678b086e-c148-8003-80b0-466ca2bea5df

いまいちピンとこない。

これはどういう時に使うんだろう、Optional セグメントや Splats と組み合わせるのだろうか?

loader や action が使えなくなるようなので、あまり使わない方が良さそうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Route Module

routes.ts から参照されるファイルは Route Module と呼ばれ、次のような定義を含む。

  • 自動コード分割
  • データロード
  • アクション
  • リバリデーション
  • エラー境界
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Component (default)

export default されるコンポーネントがルートマッチ時に描画される。

app/routes/my-route.tsx
export default function MyRouteComponent() {
  return (
    <div>
      <h1>Look ma!</h1>
      <p>
        I'm still using React Router after like 10 years.
      </p>
    </div>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

loader

loader はコンポーネントの描画前に呼び出され、コンポーネントにデータを提供する。

サーバーレンダリング時または事前ビルド中にサーバーでのみ実行される。

コード例
export async function loader() {
  return { message: "Hello, world!" };
}

export default function MyRoute({ loaderData }) {
  return <h1>{loaderData.message}</h1>;
}

これもしっかりコード補完が効くんだろうな。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

clientLoader

ブラウザでのみ呼び出され、loader に加えて or loader の代わりにコンポーネントにデータを供給する。

コード例
export async function clientLoader({ serverLoader }) {
  // call the server loader
  const serverData = await serverLoader();
  // And/or fetch data on the client
  const data = getDataFromClient();
  // Return the data to expose through useLoaderData()
  return data;
}

Client loaders can participate in initial page load hydration of server rendered pages by setting the hydrate property on the function:

これがよくわからん。

https://chatgpt.com/share/678b26a2-34dc-8003-88fa-5dedbee8206d

https://remix.run/docs/ru/main/route/client-loader#clientloaderhydrate

https://remix.run/docs/en/main/guides/client-data#fullstack-state

https://chatgpt.com/share/678b286b-4f30-8003-8047-ff6056845206

きっといつか必要な時が来るのだろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

action

サーバーサイドでデータ更新を行うときに使用する、更新後は loader や clientLoader が自動的に実行される。

Form、useFetcher、useSubmit のいずれかから呼び出せる。

コード例
// route("/list", "./list.tsx")
import { Form } from "react-router";
import { TodoList } from "~/components/TodoList";

// this data will be loaded after the action completes...
export async function loader() {
  const items = await fakeDb.getItems();
  return { items };
}

// ...so that the list here is updated automatically
export default function Items({ loaderData }) {
  return (
    <div>
      <List items={loaderData.items} />
      <Form method="post" navigate={false} action="/list">
        <input type="text" name="title" />
        <button type="submit">Create Todo</button>
      </Form>
    </div>
  );
}

export async function action({ request }) {
  const data = await request.formData();
  const todo = await fakeDb.addItem({
    title: data.get("title"),
  });
  return { ok: true };
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

clientAction

ブラウザ内から呼び出される action のようなものらしい、使い所がわからない。

コード例
export async function clientAction({ serverAction }) {
  fakeInvalidateClientSideCache();
  // can still call the server action if needed
  const data = await serverAction();
  return data;
}

https://remix-docs-ja.techtalk.jp/guides/client-data

このページにユースケースが書いてあるので参考になった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ErrorBoundary

https://reactrouter.com/start/framework/route-module#errorboundary

エラー発生時に描画されるコンポーネントのようだ。

コード例
import {
  isRouteErrorResponse,
  useRouteError,
} from "react-router";

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>{error.data}</p>
      </div>
    );
  } else if (error instanceof Error) {
    return (
      <div>
        <h1>Error</h1>
        <p>{error.message}</p>
        <p>The stack trace is:</p>
        <pre>{error.stack}</pre>
      </div>
    );
  } else {
    return <h1>Unknown Error</h1>;
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

HydrateFallback

https://reactrouter.com/start/framework/route-module#hydratefallback

最初のページロード時、clientLoader が完了してからコンポーネントが描画されるが、HydrateFallback をエクスポートすることで clientLoader の完了を待たずに直ちに描画されるようになるようだ。

コード例
export async function clientLoader() {
  const data = await fakeLoadLocalGameData();
  return data;
}

export function HydrateFallback() {
  return <p>Loading Game...</p>;
}

export default function Component({ loaderData }) {
  return <Game data={loaderData} />;
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

handle

https://reactrouter.com/start/framework/route-module#handle

これは全くもって謎だ。

コード例
export const handle = {
  its: "all yours",
};

https://remix.run/docs/en/main/guides/breadcrumbs#breadcrumbs-guide

https://remix.run/docs/en/main/hooks/use-matches

useRouteMatch フックと組み合わせて使うようだ。

任意のデータを格納できるのでパンくずリストを作成する時に便利そうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ハイドレーションについておさらい

ハイドレーションって何となく理解しているつもりだけど、自分の言葉では説明できない。

  • サーバーでコンポーネントが描画される
  • クライアントに送信される
  • クライアントでイベントハンドラなどを実行できるようにクライアントで描画する
  • サーバーの描画結果をクライアント描画結果で置き換える

こんな感じなのだろうか?

https://chatgpt.com/share/678b66e3-d400-8003-adcf-e6a253eb12da

ハイドレーションとは?
ハイドレーションとは、サーバーで事前に生成された HTML コンテンツに対して、クライアント(ブラウザ)側で JavaScript を利用してインタラクティブな動作を付与するプロセスを指します。

サーバーサイドレンダリング (SSR)
サーバーが初期 HTML を生成してクライアントに送信します。この時点で、HTML はすでに完成しており、ユーザーはすぐに内容を視認できます。

ハイドレーション
クライアント側で JavaScript がロードされ、サーバーから送られてきた HTML を基に、アプリケーションの状態やイベントリスナーなどの機能を復元します。このステップを「ハイドレーション」と呼びます。

最後は置き換えではなく同期のようだ。

これを踏まえて下記を翻訳してみる。

Client loaders can participate in initial page load hydration of server rendered pages by setting the hydrate property on the function:

hydrate プロパティを設定することで、クライアントローダーはサーバーが描画したページの最初のページロードのハイドレーションに参加することができる。

HydrateFallback の説明を見るに、何もしないとクライアントローダーの実行が完了するまでコンポーネントは描画されないのだろう。

一方、hydrate プロパティを指定することでサーバーローダーの実行が完了したら即コンポーネントを描画できるようになる。

こんな感じだろうか?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

検証用のコードを書いてみた

app/routes/hydrate.tsx
import { Link, useLoaderData } from "react-router";
import type { Route } from "./+types/hydrate";

export async function loader() {
  await new Promise((resolve) => setTimeout(resolve, 100));
  return { server: true };
}

export async function clientLoader() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return { client: true };
}

// clientLoader.hydrate = true as const;

export default function Hydrate({ loaderData }: Route.ComponentProps) {
  return (
    <div>
      <h1>Hydrate</h1>
      <pre>{JSON.stringify(loaderData)}</pre>
      <Link to="/hydrate">Hydrate</Link>
    </div>
  );
}

hydrate を true にすると 1 秒経過後に loaderData が clientLoader のものになる。

hydrate を false にすると loaderData は loader のものになるが、Hydrate リンクをクリックすると 1 秒経過後に loaderData が clientLoader のものになる。

なるほど、ちょっとわかったかも知れない。