19
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactAdvent Calendar 2024

Day 3

React Router v7 がついに Remix と合流したので情報整理

Last updated at Posted at 2024-12-02

はじめに

React Conf 2024話題になった React Router v7 がついに2024/11/22に Remix と合流しましたので公式ドキュメントを読みながら情報を整理します。

react-router-remix-graphic.jpeg

Graphic by Jacob Paris

React Router v7

React Router v7 の公式ドキュメントを読みながら、ポイントをまとめていきます。

Non-breaking
Upgrading from v6 to v7 is a non-breaking upgrade. Keep using React Router the same way you already do.

v6 から v7 へのアップグレードは非破壊アップグレードです。これまでと同じように React Router を使い続けてください。

Bridge to React 19
All new bundling, server rendering, pre-rendering, and streaming features allow you bridge the gap from React 18 to 19 incrementally.

新しいバンドル、サーバーレンダリング、プリレンダリング、ストリーミング機能により、React 18 から 19 へのギャップを段階的に埋めることができます。

Type Safety
New typegen provides first class types for route params, loader data, actions, and more.

新しい typegen は、ルートパラメータ、ローダーデータ、アクションなどのファーストクラスの型を提供します。

ライブラリとしての React Router

v7 でも引き続き、シンプルで宣言的なルーティングライブラリとして使用できます。URL をコンポーネントと一致させ、URL データへのアクセスを提供し、アプリ内を遷移します。

ReactDOM.createRoot(root).render(
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="dashboard" element={<Dashboard />}>
        <Route index element={<RecentActivity />} />
        <Route path="project/:id" element={<Project />} />
      </Route>
    </Routes>
  </BrowserRouter>
);

(出典) React Router as a framework

フレームワークとしての React Router

React Router が Remix と統合されたことで Remix の特徴を受け継ぎ 「Web 標準」 に沿うものとなりました。基本的なデータの受け渡しの考え方は以下の図の通りです。

image.png

React Router を React フレームワークとして活用できるようになりました。一般的な Web プロジェクトに必要な以下のような多数の機能を提供します。

和訳・意訳 原文
Viteバンドラーと開発サーバーの統合 Vite bundler and dev server integration
モジュールの即時適用 hot module replacement
コード分割​ code splitting
型安全性を考慮したルート定義 route conventions with type safety
ファイルシステムまたは設定に基づくルーティング file system or config-based routing
型安全性を考慮したデータ読み込み data loading with type safety
型安全性のあるアクション actions with type safety
アクション後のページデータの自動再検証 automatic revalidation of page data after actions
SSR、SPA、静的レンダリング SSR, SPA, and static rendering strategies
保留状態と楽観的UIのためのAPI APIs for pending states and optimistic UI
デプロイメントアダプタ deployment adapters

ルーティング
各ルートをコード分割し、パラメーターとデータの型安全性を提供。ユーザーが遷移したときに保留中の状態にアクセスしてデータを自動的に読み込みます。

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

export default [
  index("./home.tsx"),
  route("about", "./about.tsx"),

  layout("./auth/layout.tsx", [
    route("login", "./auth/login.tsx"),
    route("register", "./auth/register.tsx"),
  ]),

  ...prefix("concerts", [
    index("./concerts/home.tsx"),
    route(":city", "./concerts/city.tsx"),
    route(":city/:id", "./concerts/show.tsx")
    route("trending", "./concerts/trending.tsx"),
  ]),
] satisfies RouteConfig;

ローダー
ローダーはルートコンポーネントにデータを提供します。

// loaders provide data to components
export async function loader({ params }: Route.LoaderArgs) {
  const [show, isLiked] = await Promise.all([
    fakeDb.find("show", params.id),
    fakeIsLiked(params.city),
  ]);
  return { show, isLiked };
}

コンポーネント

コンポーネントは、プロパティとして渡されたローダーデータを使用して、ルーティングで指定された URL にてレンダリングされます。

export default function Show({
  loaderData,
}: Route.ComponentProps) {
  const { show, isLiked } = loaderData;
  return (
    <div>
      <h1>{show.name}</h1>
      <p>{show.description}</p>

      <form method="post">
        <button
          type="submit"
          name="liked"
          value={isLiked ? 0 : 1}
        >
          {isLiked ? "Remove" : "Save"}
        </button>
      </form>
    </div>
  );
}

アクション
アクションによってデータを更新し、ページ上のすべてのデータの再検証をトリガーにして、UI を自動的に最新の状態に維持することができます。

export async function action({
  request,
  params,
}: Route.LoaderArgs) {
  const formData = await request.formData();
  await fakeSetLikedShow(formData.get("liked"));
  return { ok: true };
}

(出典)React Router as a framework

loader の深堀り

loader / ルートローダーは、ルートコンポーネントがレンダリングされる前にデータを提供します。 これらはサーバーレンダリング時、またはプリレンダリングによるビルド時にのみサーバー上で呼び出されます。

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

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

clientLoader はブラウザでのみ呼び出されます。 route loader に加えてコンポーネントにデータ提供します。 route 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;
}

cliendLoaderhydrate プロパティを設定することで、サーバ・レンダリングされたページの初期ページロード時のハイドレーションに参加することができます。

constとして使用することで、TypeScriptはclientLoader.hydrateの型がbooleanではなくtrueであることを推測します。 これにより、React RouterはclientLoader.hydrateの値に基づいてloaderDataの型を導出することができます。

export async function clientLoader() {
  // ...
}
clientLoader.hydrate = true as const;

クライアントデータの読み込み

clientLoader クライアントでデータを取得するために使用されます。これは、ブラウザからのみデータを取得したいページやプロジェクト全体に役立ちます。

app/product.tsx
// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";

export async function clientLoader({
  params,
}: Route.ClientLoaderArgs) {
  const res = await fetch(`/api/products/${params.pid}`);
  const product = await res.json();
  return product;
}

export default function Product({
  loaderData,
}: Route.ComponentProps) {
  const { name, description } = loaderData;
  return (
    <div>
      <h1>{name}</h1>
      <p>{description}</p>
    </div>
  );
}

サーバーデータの読み込み
サーバーレンダリングの際、loader は最初のページロードとクライアントナビゲーションの両方に使用されます。クライアントのナビゲーションは、ブラウザからサーバーへのReact Routerによる自動フェッチ / fetch を通じて loader を呼び出します。

ローダー関数 / loader はクライアント・バンドルから削除されるので、ブラウザに含まれることを気にすることなく、サーバーのみのAPIを使うことができます。

app/product.tsx
// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";
import { fakeDb } from "../db";

export async function loader({ params }: Route.LoaderArgs) {
  const product = await fakeDb.getProduct(params.pid);
  return product;
}

export default function Product({
  loaderData,
}: Route.ComponentProps) {
  const { name, description } = loaderData;
  return (
    <div>
      <h1>{name}</h1>
      <p>{description}</p>
    </div>
  );
}

他にも Static Data Loading でプリレンダリングする URL を react-router.config.ts で指定することができます。

loaderclientLoader は一緒に使うことができます。 loader は最初のSSR(またはプリレンダリング)のためにサーバ上で使用され、clientLoader はその後のクライアントサイドのナビゲーションのために使用されます。

(番外編:Suspense
React Suspense を使用したスト​​リーミングにより、アプリは重要でないデータを延期し、UI レンダリングをブロック解除することで初期レンダリングを高速化できます。

React Router は、ローダーとアクションから Promise を返すことで React Suspense をサポートします。

(出典)Route Module > loader / Route Module > clientLoader / Data Loading

action の深堀り

action / ルートアクションを使用すると、<Form>useFetcheruseSubmit から呼び出されたときに、ページ上のすべての読み込みデータを自動的に再検証して、サーバー側のデータ変更が可能になります。

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

// このデータはアクションが完了した後に読み込まれます...
export async function loader() {
  const items = await fakeDb.getItems();
  return { items };
}

// ...ここでのリストは自動的に更新されます
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 };
}

clientAction はブラウザからのみ呼び出されるルートアクションです。

export async function clientAction({ serverAction }) {
  fakeInvalidateClientSideCache();
  // 必要に応じてサーバーアクションを呼び出すことができる
  const data = await serverAction();
  return data;
}

クライアントアクション
クライアントアクションはブラウザー内でのみ実行され、両方が定義されている場合はサーバーアクションよりも優先されます。

app/project.tsx
// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { someApi } from "./api";

export async function clientAction({
  request,
}: Route.ClientActionArgs) {
  let formData = await request.formData();
  let title = await formData.get("title");
  let project = await someApi.updateProject({ title });
  return project;
}

export default function Project({
  actionData,
}: Route.ComponentProps) {
  return (
    <div>
      <h1>Project</h1>
      <Form method="post">
        <input type="text" name="title" />
        <button type="submit">Submit</button>
      </Form>
      {actionData ? (
        <p>{actionData.title} updated</p>
      ) : null}
    </div>
  );
}

サーバーアクション
サーバーアクションはサーバー上でのみ実行され、クライアントバンドルからは削除されます。

app/project.tsx
// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { fakeDb } from "../db";

export async function action({
  request,
}: Route.ActionArgs) {
  let formData = await request.formData();
  let title = await formData.get("title");
  let project = await fakeDb.updateProject({ title });
  return project;
}

export default function Project({
  actionData,
}: Route.ComponentProps) {
  return (
    <div>
      <h1>Project</h1>
      <Form method="post">
        <input type="text" name="title" />
        <button type="submit">Submit</button>
      </Form>
      {actionData ? (
        <p>{actionData.title} updated</p>
      ) : null}
    </div>
  );
}

アクションの呼び出し
action はルートのパスと「post」メソッドを参照することにより <Form>useSubmit を通じて宣言的に呼び出されます。

<fetcher.Form>fetcher.submit も可

アクション後にはナビゲーションが実行され、ブラウザの履歴に追加されます。

例)<Form> でアクションを呼び出す。

import { Form } from "react-router";

function SomeComponent() {
  return (
    <Form action="/projects/123" method="post">
      <input type="text" name="title" />
      <button type="submit">Submit</button>
    </Form>
  );
}

例)useSubmit でアクションを呼び出す

import { useCallback } from "react";
import { useSubmit } from "react-router";
import { useFakeTimer } from "fake-lib";

function useQuizTimer() {
  let submit = useSubmit();

  let cb = useCallback(() => {
    submit(
      { quizTimedOut: true },
      { action: "/end-quiz", method: "post" }
    );
  }, []);

  let tenMinutes = 10 * 60 * 1000;
  useFakeTimer(tenMinutes, cb);
}

ナビゲーションを発生させず、ブラウザ履歴に残さなずアクションを送信するにはフェッチャーを使用します。

例)useFetcher でアクションを呼び出す

import { useFetcher } from "react-router";

function Task() {
  let fetcher = useFetcher();
  let busy = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action="/update-task/123">
      <input type="text" name="title" />
      <button type="submit">
        {busy ? "Saving..." : "Save"}
      </button>
    </fetcher.Form>
  );
}

(出典)Route Module > action / Route Module > clientAction / Actions

ErrorBoundary の深堀り

他のルートモジュールAPIが投げるとき、ルートコンポーネントの代わりにルートモジュールの ErrorBoundary がレンダリングされます。これはRemix の ErrorBoundary を踏襲しているようです。

Remixの ErrorBoundary コンポーネントは、通常の React のエラー境界と同じように動作しますが、いくつかの追加機能があります。 ルートコンポーネントにエラーがあると、ErrorBoundary はその場所にレンダリングされ、親ルートの内部にネストされます。 ErrorBoundary コンポーネントは、ルートのローダーやアクション関数でエラーが発生したときにもレンダリングされます。

最も一般的なユースケースは次のようなものです。

  • UI エラーのトリガーとして意図的に 4xx レスポンスをスローする
    • ユーザーの入力に問題があった場合に 400 をスローする
    • 不正アクセスに対して 401 をスローする
    • リクエストされたデータが見つからない場合に 404 をスローする
  • React は、レンダリング中にランタイムエラーと遭遇すると、意図せずに Error をスローすることがあります

スローされたオブジェクトを取得するには、useRouteError フックを使用します。 Response がスローされると、ステート/ステータステキスト/データフィールドを持つ ErrorResponse インスタンスに自動的にアンラップされます。スローされたレスポンスとスローされたエラーを区別するには、isRouteErrorResponse ユーティリティを使用します。

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

(出典)Route Module > ErrorBoundary / Remix > ErrorBoundary

Hydrate 深堀り

最初のページロードでは、ルートコンポーネントはクライアントローダが終了した後にのみレンダリングされます。エクスポートされた場合、ルートコンポーネントの代わりに HydrateFallback を直ちにレンダリングすることができます。

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

(出典)Route Module > HydrateFallback

他にも

headers, links, meta などがあり、SEOやアセット読み込みを意識した部品が提供されているようです。

redirectuseNavigate などは継続して利用できるようです。

Pending UI は新しいページを開いたり、アクションにデータを送信した際の非同期処理を待つ保留状態を扱うものです。

テスト

コンポーネントが useLoaderData<Link> などを使用する場合、React Routerアプリのコンテキストでレンダリングする必要があります。createRoutesStub 関数は、コンポーネントを分離してテストするためのコンテキストを作成します。

createRoutesStub を使うことでローダー、アクション、コンポーネントを持つルートモジュールに似たオブジェクトを受け取りテストします。

product-code.tsx
import { useActionData } from "react-router";

export function LoginForm() {
  const errors = useActionData();
  return (
    <Form method="post">
      <label>
        <input type="text" name="username" />
        {errors?.username && <div>{errors.username}</div>}
      </label>

      <label>
        <input type="password" name="password" />
        {errors?.password && <div>{errors.password}</div>}
      </label>

      <button type="submit">Login</button>
    </Form>
  );
}
test-code.tsx
import { createRoutesStub } from "react-router";
import * as Test from "@testing-library/react";
import { LoginForm } from "./LoginForm";

test("LoginForm renders error messages", async () => {
  const USER_MESSAGE = "Username is required";
  const PASSWORD_MESSAGE = "Password is required";

  const Stub = createRoutesStub([
    {
      path: "/login",
      Component: LoginForm,
      action() {
        return {
          errors: {
            username: USER_MESSAGE,
            password: PASSWORD_MESSAGE,
          },
        };
      },
    },
  ]);

  // render the app stub at "/login"
  Test.render(<Stub initialEntries={["/login"]} />);

  // simulate interactions
  Test.user.click(screen.getByText("Login"));
  await Test.waitFor(() => screen.findByText(USER_MESSAGE));
  await Test.waitFor(() =>
    screen.findByText(PASSWORD_MESSAGE)
  );
});

(出典)Testing

レンダリングの選択肢

React Router は3種類のレンダリングの選択肢を提供します。

CSR (Client Side Rendering)
SSR (Server Side Rendering)
SSG (Static Pre-rendering) *1 筆者はSSGと理解しました

react-router-config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,  // false で CSR, true で SSR
} satisfies Config;

ssr:false とすると常にクライアントサイドでレンダリングされます。SPA を構築する場合は、サーバーレンダリングを無効にしてください。

サーバーサイドレンダリングには、サーバーへのデプロイが必要です。ssr:true はグローバルな設定ですが、個々のルートで静的にプリレンダリングできます。また、ルートは clientLoader でクライアントデータのロードを使用し、UIの一部分のサーバーレンダリング/フェッチを避けることができます。

Client Rendering, Server Rendering の詳細は公式ドキュメントを参照ください。

(出典)Rendering Strategies / Custom Framework

アップグレード

React Router v6 から v7 へのアップグレード

v6 → v7 のアップグレードでは、フューチャーフラグを有効にすれば変更点はありません。フィーチャーフラグを利用して一つずつ段階的に変更を適用しながら進めることができます。

Remix v2 から React Router v7 へのアップグレード

割愛します。公式ドキュメントを参照してください。

おわりに

React フレームワークを選択する上で Next.js や Remix は選択肢に上がると思います。筆者は既に React Router を使っているので v7 にバージョンアップすることで Remix の Web 標準に沿ったフレームワークを使い始める環境が整うので、まずは v7 にバージョンしてみたいと思いました。

(おまけ)
公式ドキュメントを読みながら調べていく中で日本語訳サイトを発見したので共有します。

19
2
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
19
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?