5
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?

React Router v7 さわってみた

Last updated at Posted at 2024-12-04

1. この記事について

待望のReact Router v7のstatic版が登場したので
チュートリアルを一通りやってみます

2. React Router v7について

最近はSPAといえどルーティングしたいというのが一般的になっていて、SPA書くときにも素のReactで書くのではなくReact-RouterかRemix SPAモードかを選択するケースが増えているのではないかと思います。npx create-react-appが非推奨になったこともあってその傾向が助長されていて、2024年1月に公開された以下のスライドからもそんな雰囲気が伺えます
https://speakerdeck.com/nkzn/the-spas-chronicle-reaches-to-remix

2024年5月、React Routerがv7からRemixと統合されるというアナウンスが出て、Remix SPAモード勢が喜んだり?、React Router勢がまた書き方変わるのかと不平を並べたりしていました
https://remix.run/blog/merging-remix-and-react-router

そして、待望のReact Router v7 安定版初期リリースであるv7.0.1が2024年11月に公開されました。ちょうど新しいSPAを書く予定があったので、今回はこれで書こうとチュートリアル読みつつメモ書きしていくというのがこの記事です
https://remix.run/blog/react-router-v7

3. インストール

node.jsを入れた環境で以下を実行します

terminal
npx create-react-router@latest my-app

開発サーバーを起動します

terminal
cd my-app
npm i
npm run dev

開発サーバーを起動すると http://localhost:5173 に初期ページが待機します

image.png

ディレクトリ構造はこんな感じ

root.tsxから始まって、routes.tsに書いているルーティング設定通りにコンポーネントを開くようになっています

4. Routing

ということで、まずはroutes.tsを読めるようにならないと何も分かりません
routes.tsにどう書けば良いのかやり方を見ていきます

4-1. Routingの手動設定

app/routes.tsでrouting方式の設定ができます

npx create-react-routerで生成されるroutes.tsは以下のようになっていて、routes/home.tsxをインデックスとするシングルページ構成を指示しています

app/routes.ts
import {
  type RouteConfig, 
  index 
} from "@react-router/dev/routes";

export default [
  index("routes/home.tsx")
] satisfies RouteConfig;

公式ドキュメントに書かれているシンプル版が以下で、/some/pathに./some/file.tsxを表示するようにルーティングします。表示するページが固定されているならこれだけで問題ないですね

app/routes.ts
import {
  type RouteConfig,
  route,
} from "@react-router/dev/routes";

export default [
  route("some/path", "./some/file.tsx"),
  // pattern ^           ^ module file
] satisfies RouteConfig;

indexとrouteを組み合わせて色々足したのが以下

app/routes.ts
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;

4-2. Nested Routes

共通のナビゲーションバーやサイドバーを表示するような場合に、共通部分を分けて書ける仕組みがNested Routesです。以下では./dashboard.tsxに共通部分を書いて、./home.tsx, ./settings.tsxがそれぞれのURLに表示する内容を書いていて、URLで見ると/dashboard/に./dashboard.tsx+./home.tsx、/dashboard/settings/に./dashboard.tsx+./settings.tsxが表示されるわけです

app/routes.ts
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;

共通部分になる./dashboard.tsxには個別部分をOutlet要素として書くことで自動的に置き換えて表示してくれる仕組みです。これはRemixと同様のスタイルですね

app/dashboard.tsx
import { Outlet } from "react-router";

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* will either be home.tsx or settings.tsx */}
      <Outlet />
    </div>
  );
}

4-3. Layout Routes

routeの代わりにlayoutを使うとNestしつつURLの移動はないというのが書けます

以下の場合は
/ に./dashboard.tsx+./home.tsx が表示
/settings/に./dashboard.tsx+./settings.tsxが表示
となります

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

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

4-4. Route Prefixes

prefixを使うと親ファイルなしでNestできます

/projects/ に./projects/home.tsx
/projects/:pid に./projects/project.tsx
/projects/:pid/edit に./projects/edit-project.tsx
を表示します

app/routes.ts
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;

4-5. Dynamic Segment

4-5-1. 単独

動的なルーティングをしたい場合は以下のように「:」を頭に付けます
例えば以下のように書くと /teams/123/ が team.tsxに引数teamId=123としてルーティングされます

route("teams/:teamId", "./team.tsx"),

ページコンポーネント側はデータ受け取りを担当するloaderさんにparamsとして受け取るように書いていて、params.teamIdとしてコンポーネント内で使えるように書きます

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

4-5-2. 複数

複数の動的なセグメントを入れることもできます

route("c/:categoryId/p/:productId", "./product.tsx"),
import type { Route } from "./+types/product";

async function loader({ params }: LoaderArgs) {
  //                    ^? { categoryId: string; productId: string }
}

4-6. Optional Segments

セグメントがあってもなくても同じファイルにルーティングする場合「?」をつけてやります

例えば以下だと動的セグメントである /:lang/ があってもなくても ./categories.tsx にルーティングされます

route(":lang?/categories", "./categories.tsx"),

動的でないセグメントもOptionalにできます

route("users/:userId/edit?", "./user.tsx");

4-7. Splats

「*」で指定するとそこに何がきても同じファイルにルーティングできます

route("files/*", "./files.tsx"),

コンポーネント側はparams["*"]で受け取れます

./files.tsx
export async function loader({ params }: Route.LoaderArgs) {
  // params["*"] will contain the remaining URL after files/
}

params[""] と書くのがちょっとめんどい、という方は適当な名前に代入して扱えます。例えば以下のように書くと const splat = params[""] として扱うことができます

const { "*": splat } = params;

4-8. Component Routes

なんとTSXタグとしてルーティングが書けるようです

例えば下のように書くと
/ に <StepOne />
/step-2/ に <StepTwo />
/step-3/ に <StepThree />
が表示されます

import { Routes, Route } from "react-router";

function Wizard() {
  return (
    <div>
      <h1>Some Wizard with Steps</h1>
      <Routes>
        <Route index element={<StepOne />} />
        <Route path="step-2" element={<StepTwo />} />
        <Route path="step-3" element={<StepThree />}>
      </Routes>
    </div>
  );
}

混乱しそう
慣れるのだろうか?

なお、この書き方だとloader, actionなどのRoute Moduleの機能が使えないようです

5. Route Module

以下の./team.tsxのように routes.ts で割り当てているファイルをRoute Moduleというようです

/routes.ts
route("teams/:teamId", "./team.tsx"),
//           route module ^^^^^^^^

Route Moduleには以下のような機能があるようです

・automatic code-splitting
・data loading
・actions
・revalidation
・error boundaries
・and more

5-1. 基本の書き方

Route Moduleと言ってますが、Reactのコンポーネントと同じ書き方が基本型で、ここにReact Router独自の機能を追加したものをRoute Moduleと呼んでいるようです

/routes/hoge.tsx
export default function MyRoute() {
  return (
    <div>
      <h1>Look ma!</h1>
      <p>
        I'm still using React Router after like 10 years.
      </p>
    </div>
  );
}

5-2. ErrorBoundary

ルーティング処理でエラーが出た時にエラーハンドリングすることができます。データフェッチなどでエラーが出た場合に専用の表示を出すような使い方が想定されているようです

routes/hoge.tsx
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>;
  }
}

5-3. HydrateFallback

データフェッチなどの処理が入るページで、処理が完了するまでの間にローディングページなどを表示することができます

routes/hoge.tsx
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} />;
}

5-4. HTTP headers

HTTPヘッダを以下のように書くことができます

routes/hoge.tsx
export function headers() {
  return {
    "X-Stretchy-Pants": "its for fun",
    "Cache-Control": "max-age=300, s-maxage=3600",
  };
}

5-5. handle

パンくずリストのようなルート経路を使った表示をつくりたい場合、以下のように各コンポーネントでexport constしておいて、useMatchesで受け取ることができます

routes/index.tsx
export const handle = {
  breadcrumb: "Home",
};

export default function Index() {
  return <h1>Home Page</h1>;
}
routes/routes/about.tsx
export const handle = {
  breadcrumb: "About",
};

export default function About() {
  return <h1>About Page</h1>;
}
routes/routes/contact.tsx
export const handle = {
  breadcrumb: "Contact",
};

export default function Contact() {
  return <h1>Contact Page</h1>;
}
breadcrumbs.tsx
import { useMatches, Link } from "@remix-run/react";

export default function Breadcrumbs() {
  const matches = useMatches();

  // ルートのhandleでパンくずリスト情報が定義されているもののみ取得
  const breadcrumbs = matches
    .map((match) => {
      if (match.handle && match.handle.breadcrumb) {
        return {
          breadcrumb: match.handle.breadcrumb,
          pathname: match.pathname, // ルートのパス
        };
      }
      return null;
    })
    .filter(Boolean); // nullを取り除く

  return (
    <nav aria-label="Breadcrumb">
      <ul style={{ listStyle: "none", display: "flex", gap: "8px", padding: 0 }}>
        {breadcrumbs.map((crumb, index) => (
          <li key={crumb.pathname}>
            <Link to={crumb.pathname}>
              {crumb.breadcrumb}
            </Link>
            {index < breadcrumbs.length - 1 && <span> / </span>}
          </li>
        ))}
      </ul>
    </nav>
  );
}

5-6. links

linkタグに書く内容をまとめて定義しておいて、各ページに共通して追加することができます

links.tsx
export function links() {
  return [
    {
      rel: "icon",
      href: "/favicon.png",
      type: "image/png",
    },
    {
      rel: "stylesheet",
      href: "https://example.com/some/styles.css",
    },
    {
      rel: "preload",
      href: "/images/banner.jpg",
      as: "image",
    },
  ];
}

以下のように書くとLinks要素のところに

hoge.tsx
import { Links } from "react-router";

export default function Root() {
  return (
    <html>
      <head>
        <Links />
      </head>

      <body />
    </html>
  );
}

5-7. meta

metaタグの内容もまとめて書いておけます

export function meta() {
  return [
    { title: "Very cool app" },
    {
      property: "og:title",
      content: "Very cool app",
    },
    {
      name: "description",
      content: "This app is the best",
    },
  ];
}
hoge.tsx
import { Meta } from "react-router";

export default function Root() {
  return (
    <html>
      <head>
        <Meta />
      </head>

      <body />
    </html>
  );
}

5-8. shouldRevalidate

routes.tsにshouldRevalidate関数を書いておくとrevalidateするかどうか指示することが出来ます。ユーザーがフォーム送信するなどしてデータが更新されたときに、値を再評価してレンダリングしなおすrevalidateがデフォルトではすべてのページで有効になっていますが、その必要がないページはrevalidate無効としておくことでパフォーマンスを改善できます

routes.ts
import type { Route } from "./+types/my-route";

export function shouldRevalidate(
  arg: Route.ShouldRevalidateArg
) {
  return true;
}

shouldRevalidateの引数には以下のような値を受け取れるので、これを使ってrevalidateするかどうか条件分岐することができます

key 内容
currentUrl 現在のURL
nextUrl ナビゲーション後のURL
defaultShouldRevalidate React Routerがデフォルトで適用するshouldRevalidateロジックの結果
formMethod 現在のフォーム送信のHTTPメソッド("POST", "PUT"など)
formData フォーム送信に関連するデータ(FormDataオブジェクト)
currentParams 現在のルートパラメータ
nextParams ナビゲーション後のルートパラメータ
routes.ts
import {
  type RouteConfig,
  layout,
  route,
  index,
} from "@react-router/dev/routes";

// shouldRevalidateの定義例
function myShouldRevalidate({ currentUrl, nextUrl }: { currentUrl: URL; nextUrl: URL }) {
  return currentUrl.pathname !== nextUrl.pathname; // パスが変更された場合のみ再取得
}

export default [
  // parent route
  layout("./dashboard.tsx", [
    // child routes
    index("./home.tsx", { shouldRevalidate: myShouldRevalidate }), // ホームルートに追加
    route("settings", "./settings.tsx", { shouldRevalidate: myShouldRevalidate }), // 設定ルートに追加
  ]),
] satisfies RouteConfig;

6. loader, action

6-1. コンセプトとか経緯とか

2020年11月にRemixがloader, actionを提案してフロントエンドを書くときにいちいちデータ更新を考える必要をなくそう、バックエンドのデータを更新したらフロントエンドも更新されるように勝手になる仕組みを作ろう、というコンセプトを提唱しました
https://remix.run/docs/en/main/discussion/data-flow
https://remix.run/blog/remix-data-flow

SSRとCSRが混在している現在のフロントエンドでこのコンセプトがすごく良いというのは僕にも分かりますし、実際界隈にも受け入れられたようで2022年9月のReact Routerもv6.4からloader, actionを導入、2024年春にRemixとのマージをブログで発表、2024年11月v7がリリースされています
https://remix.run/blog/merging-remix-and-react-router

React Router v6.4について書かれたブログを見ると少なからず驚きをもって迎えられている雰囲気が伝わってきます
https://zenn.dev/monicle/articles/react-router-v6-4?utm_source=chatgpt.com

そうして導入されてきたloader, actionはReact Router v7ではどうなっているでしょうか

6-2. Rendering Strategies

SSRを有効にするか、どのページをprerenderするかまとめて設定しておくことができます

6-2-1. Server Side Rendering

デフォルトだとSSRが有効になっています

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

export default {
  ssr: true,
} satisfies Config;

6-2-2. Client Side Rendering

SPAにしたいときは無効にします

react-router.config.ts
  ssr: false,

6-2-3. Static Pre-rendering

prerenderに指定しておくことでビルド時に静的ファイルを生成してくれるようです

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

export default {
  // return a list of URLs to prerender at build time
  async prerender() {
    return ["/", "/about", "/contact"];
  },
} satisfies Config;

公式ドキュメントにpre-renderingのページがあり、ここを読むと他の書き方も紹介されています
https://reactrouter.com/how-to/pre-rendering#pre-rendering

以下のように3つの書き方がまとめて紹介されていますが、通常はどれか1つを選択するのではないかと思います

prerender: trueは静的なrouteを全部pre-renderするけど動的なrouteは対象外
prerender: ["/", "/blog"]は指定のpathのrouteをpre-render
async prerender()はCMSなどのフェッチデータなどから動的にrouteを指定できるようです

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

export default {
  // all static route paths
  // (no dynamic segments like "/post/:slug")
  prerender: true,

  // any url
  prerender: ["/", "/blog", "/blog/popular-post"],

  // async function for dependencies like a CMS
  async prerender({ getStaticPaths }) {
    let posts = await fakeGetPostsFromCMS();
    return ["/", "/blog"].concat(
      posts.map((post) => post.href)
    );
  },
} satisfies Config;

動的に指定できるってことはSSGも出来るのかな?

6-3. loader

React Router v7ではrouting, loader, actionが一体となって動作します
データの受け取りをすべてloader内でやると適切なタイミングでrevalidateしてくれます

loaderとclientLoaderの2種類が用意されていて、SSRやstatic pre-renderingのときはloader、CSRのときはclientLoaderを使います

6-3-1. Client Data Loading

clientLoader関数内でデータフェッチしてreturnしてやると、Route Module側でloaderDataとして受け取れます

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

clientLoaderはreact-router.config.tsxでssr: falseにしていても使う事ができますので、SPAをビルドして使うときもclient側フェッチはこちらで書くことになります
https://reactrouter.com/how-to/spa

6-3-2. Server Data Loading

こちらも同様にデータフェッチしてreturnしてやるとloaderDataとして受け取れます
サーバーサイド側はDBから取得すると思いますので、prismaなどを使って取ってくる感じでしょうか

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

6-3-3. Static Data Loading

プリレンダリングするときは普通にloaderで書いておいて、react-router.config.tsxでprerenderするだけです

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

export async function loader({ params }: Route.LoaderArgs) {
  let product = await getProductFromCSVFile(params.pid);
  return product;
}

export default function Product({
  loaderData,
}: Route.ComponentProps) {
  const { name, description } = loaderData;
  return (
    <div>
      <h1>{name}</h1>
      <p>{description}</p>
    </div>
  );
}
react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  async prerender() {
    let products = await readProductsFromCSVFile();
    return products.map(
      (product) => `/products/${product.id}`
    );
  },
} satisfies Config;

6-3-4. 両方使う

なんと、loaderとclientLoaderの両方を同時に使う事ができます
受け取り側はloaderDataでまとめて受け取り

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

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

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

export default function Product({
  loaderData,
}: Route.ComponentProps) {
  const { name, description } = loaderData;

  return (
    <div>
      <h1>{name}</h1>
      <p>{description}</p>
    </div>
  );
}

素晴らしい
早くこれで書きたい

6-4. action

バックエンドへの書き込みを担当するのがactionです
データの更新処理をactionに書いておいて、actionをトリガーするイベントが発生するとrevalidateしてくれます

6-4-1. clientAction

client側で実行されるべきアクションはclientActionに書きます
以下の場合は、Form内のsubmitボタンがactionをトリガーしてくれます

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

6-4-2. action

actionも書き方は同じですがサーバーサイドでのactionを想定しているので、公式ドキュメントの例でもDBへの書き込み処理を記述しています

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

6-4-3. actionをトリガーするイベント

HTTPリクエスト

先ほどの例で書かれていたFormのボタン操作のように、HTTPリクエストをトリガーとする場合です。action側でPOSTリクエストを引数requestとして受け取って処理します

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

useSubmitによりactionをトリガーできます
任意のイベントから実行できるので、これで書くことが多そうです

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

FormやuseSubmitを使うとブラウザのナビゲーション履歴が残りますが、useFetcherを使うと履歴が残らなくできます

Formを置き換える場合
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>
  );
}
useSubmitを置き換える場合
fetcher.submit(
  { title: "New Title" },
  { action: "/update-task/123", method: "post" }
);

7. Navigation

React Router v7のルーティングに沿ってページ遷移する為の仕組みとして<Link>, <NavLink>, <Form>, redirect, and useNavigateが準備されています

7-1. NavLink

ルーティングのPathを指定してリンクすることができます

アクティブやペンディングなどの状態を反映して表示などを変えたい場合はNavLink
使わない場合はLinkを使います

hoge.tsx
import { NavLink } from "react-router";

export function MyAppNav() {
  return (
    <nav>
      <NavLink to="/" end>
        Home
      </NavLink>
      <NavLink to="/trending" end>
        Trending Concerts
      </NavLink>
      <NavLink to="/concerts">All Concerts</NavLink>
      <NavLink to="/account">Account</NavLink>
    </nav>
  );
}

NavLinkにCSSを適用する場合、以下のように書くだけで上手くやってくれます

hoge.css
a.active {
  color: red;
}

a.pending {
  animate: pulse 1s infinite;
}

a.transitioning {
  /* css transition is running */
}

普通の要素と同様、stateを使ってclassNameなどを変更することができます

stateに応じてclassNameを変更する
// className
<NavLink
  to="/messages"
  className={({ isActive, isPending, isTransitioning }) =>
    [
      isPending ? "pending" : "",
      isActive ? "active" : "",
      isTransitioning ? "transitioning" : "",
    ].join(" ")
  }
>
  Messages
</NavLink>
stateに応じてstyleを変更する
// style
<NavLink
  to="/messages"
  style={({ isActive, isPending, isTransitioning }) => {
    return {
      fontWeight: isActive ? "bold" : "",
      color: isPending ? "red" : "black",
      viewTransitionName: isTransitioning ? "slide" : "",
    };
  }}
>
  Messages
</NavLink>
stateに応じて子要素のclassNameを変更
// children
<NavLink to="/tasks">
  {({ isActive, isPending, isTransitioning }) => (
    <span className={isActive ? "active" : ""}>Tasks</span>
  )}
</NavLink>

7-2. Link

アクティブやペンディングなどの状態を使わない場合はLinkを使います

import { Link } from "react-router";

export function LoggedOutMessage() {
  return (
    <p>
      You've been logged out.{" "}
      <Link to="/login">Login again</Link>
    </p>
  );
}

7-3. Form

Formを使ってナビゲートすることもできます

<Form action="/search">
  <input type="text" name="q" />
</Form>

上の場合に例えば journey と入力すると、以下のようなPathへのナビゲートを要求します

/search?q=journey

7-4. redirect

loaderやactionの中でリダイレクトすることができるようです

import { redirect } from "react-router";

export async function loader({ request }) {
  let user = await getUser(request);
  if (!user) {
    return redirect("/login");
  }
  return { userName: user.name };
}
import { redirect } from "react-router";

export async function action({ request }) {
  let formData = await request.formData();
  let project = await createProject(formData);
  return redirect(`/projects/${project.id}`);
}

7-5. useNavigate

適当なイベントからuseNavigateを使ってナビゲートできます

import { useNavigate } from "react-router";

export function useLogoutAfterInactivity() {
  let navigate = useNavigate();

  useFakeInactivityHook(() => {
    navigate("/logout");
  });
}

8. Pending UI

新しいrouteに移動するとき、actionをトリガーするユーザー入力があったときには画面はすぐ反応させたいので、待機ページを表示するか何かしら最適化した表示を出したいですが、手動でやるのはわりと面倒だったり難しかったりしたと思います。React Router v7ではRouting, loader, actionが統合的に組み込まれたことでこれを解決し、待機ページ表示を指定するだけで上手に表示してくれるようになったようです

8-1. Global Pending Navigation

Nesting Routeの親側コンポーネントで以下のように書いておくと、子コンポーネントに共通して読み込み中の表示を適用できます。以下のGlobalSpinner要素はデフォルトで存在するコンポーネントではないので、自分で何かスピナー的な要素を書いて配置する想定のサンプルコードだと思われます

import { useNavigation } from "react-router";

export default function Root() {
  const navigation = useNavigation();
  const isNavigating = Boolean(navigation.location);

  return (
    <html>
      <body>
        {isNavigating && <GlobalSpinner />}
        <Outlet />
      </body>
    </html>
  );
}

7-2. Local Pending Navigation

NavLink要素以下ではisPendingとして遷移中かどうかを取得できます。isPending === trueであるときに、遷移中表示が出るように書いておけば、遷移し終わったらページ自体が入れ替わりますので自然な待機表示が実現します

import { NavLink } from "react-router";

function Navbar() {
  return (
    <nav>
      <NavLink to="/home">
        {({ isPending }) => (
          <span>Home {isPending && <Spinner />}</span>
        )}
      </NavLink>
      <NavLink
        to="/about"
        style={({ isPending }) => ({
          color: isPending ? "gray" : "black",
        })}
      >
        About
      </NavLink>
    </nav>
  );
}

7-3. Pending Form Submission

Formやfetcher.Formも待機表示が書けます

import { useFetcher } from "react-router";

function NewProjectForm() {
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="post">
      <input type="text" name="title" />
      <button type="submit">
        {fetcher.state !== "idle"
          ? "Submitting..."
          : "Submit"}
      </button>
    </fetcher.Form>
  );
}
import { useNavigation, Form } from "react-router";

function NewProjectForm() {
  const navigation = useNavigation();

  return (
    <Form method="post" action="/projects/new">
      <input type="text" name="title" />
      <button type="submit">
        {navigation.formAction === "/projects/new"
          ? "Submitting..."
          : "Submit"}
      </button>
    </Form>
  );
}

7-4. Optimistic UI

待機中表示と同様にOptimistic UIも書くことができます
以下では開始して完了するまでの表示と完了後の表示を指定しています

function Task({ task }) {
  const fetcher = useFetcher();

  let isComplete = task.status === "complete";
  if (fetcher.formData) {
    isComplete = fetcher.formData.get("status");
  }

  return (
    <div>
      <div>{task.title}</div>
      <fetcher.Form method="post">
        <button
          name="status"
          value={isComplete ? "incomplete" : "complete"}
        >
          {isComplete ? "Mark Incomplete" : "Mark Complete"}
        </button>
      </fetcher.Form>
    </div>
  );
}

8. Testing

actionでエラーが発生した場合にキャッチしてコンポーネントに表示できます

以下ではFormをsubmitしたときにaction内でエラーが出た場合にactionからreturnされたエラー表示を画面に出して、何がまずかったかユーザーに表示しようとしています

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

この動作をテストする為にRoute Moduleのスタブを作ってテストするためのcreateRoutesStub関数が用意されています。これを使うと、単体のRoute Moduleのスタブを作って値を与えて挙動テストを実行することができます

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

まとめ

まだほとんど書けていませんが、RemixとReact Router v6に良いところ取りをしたような書き方になっているような印象です。SPAでもloader, actionが使えるようになったり、エラーハンドリングが洗練されたりと全体にパワーアップしているようですので早く書いてみたいところです

レッツトライ

5
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
5
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?