React Routerのローダーで、表示する前にデータを読み込む方法

​​React​React Router,TypeScript

React Router のデータモードでは、ルートに loader を書いておくと、そのルートのコンポーネントを描画する前にデータが読み込まれます。コンポーネントは useLoaderData で読み込み済みのデータを受け取るだけなので、useEffect で取得して「まだデータがない状態」を描画する必要がありません。

筆者は検証環境で、猫の一覧・猫のページ・ごはんの記録という3段のルートを作り、ローダーが呼ばれる時刻とコンポーネントが描画される時刻を記録して確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react-router 8.5.0、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。データはサーバーの代わりに、決まった時間(300〜800ミリ秒)だけ待ってから返す関数で用意しました。ブラウザでの確認は、esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作して行いました。

ルートに loader を書き、useLoaderData で受け取る

ローダーを使うには、公式のインストール手順のとおり、createBrowserRouter でルーターを作り、RouterProvider に渡します。ルートの定義に loader と Component を並べて書きます。この記事のコードは、検証に使ったコードから、時刻を記録する部分、エラーを試すためのルートやリンク、一部の import を省いたものです。

tsx
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';

const router = createBrowserRouter([
  {
    path: '/',
    loader: rootLoader,
    Component: Root,
    HydrateFallback: Loading,
    ErrorBoundary: ErrorPage,
    children: [
      { index: true, Component: Home },
      {
        path: 'cats/:catId',
        loader: catLoader,
        Component: CatPage,
        ErrorBoundary: ErrorPage,
        children: [{ path: 'meals', loader: mealsLoader, Component: Meals }],
      },
    ],
  },
]);

createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);

ローダーは、データを返す関数です。URL の :catId の部分は params.catId で受け取れます。

tsx
import { Link, Outlet, useLoaderData, type LoaderFunctionArgs } from 'react-router';

export async function catLoader({ params, request }: LoaderFunctionArgs) {
  const cat = await fetchCat(params.catId!, request.signal);
  // 見つからなかったときの処理は、後のエラーの節で足します
  return { cat };
}

export function CatPage() {
  const { cat } = useLoaderData<typeof catLoader>();
  return (
    <section>
      <h1>{cat.name}</h1>
      <p>毛の色:{cat.color}</p>
      <Link to={`/cats/${cat.id}/meals`}>ごはんの記録</Link>
      <Outlet />
    </section>
  );
}

useLoaderData のリファレンスにあるように、useLoaderData<typeof catLoader>() と書くと、ローダーが返す値の型が付きます。検証で、cat.name を cat.nam と書き間違えると、型チェックで次のエラーになりました。

text
errors/WrongField.tsx(6,18): error TS2551: Property 'nam' does not exist on type 'Cat'. Did you mean 'name'?

fetchCat は検証用に作った関数で、600ミリ秒待ってから猫のデータを返します。実際のアプリでは、ここで fetch を呼びます。

入れ子のルートのローダーは、同時に読み込みを始める

/cats/1/meals を直接開くと、URL に合うルートが3つ(一覧・猫のページ・ごはんの記録)あります。検証環境で、それぞれのローダーの開始と終了、コンポーネントの描画の時刻を記録しました。左の数字は、ページを開いてからの経過時間です。

text
    7ms rootLoader: 開始
    7ms catLoader(1): 開始
    7ms mealsLoader(1): 開始
    7ms createRoot して描画を始める
   18ms Loading(HydrateFallback)を描画
  307ms rootLoader: 終了
  608ms catLoader(1): 終了
  808ms mealsLoader(1): 終了
  809ms router: state=idle location=/cats/1/meals
  812ms Root を描画(navigation.state = idle)
  815ms CatPage を描画(たま)
  816ms Meals を描画(2件)

3つのローダーは同じ時刻に始まりました。親のローダーが終わるのを待ってから子のローダーを始める、という順番待ちにはなっていません。dataStrategy の説明にも、React Router は既定ではすべてのローダーを並行して実行すると書かれています。いちばん遅い 800ミリ秒のローダーが終わったところで、3つのコンポーネントがまとめて描画されました。

横軸が0〜1000ミリ秒の図。rootLoader(一覧)300ms、catLoader(猫)601ms、mealsLoader(ごはん)801ms と、かかった時間を書いた3本の水色の棒がどれも0ms付近から始まり、812msの位置にピンクの縦線「画面を描画(812ms)」がある
/cats/1/meals を開いたときの、3つのローダーの読み込み(棒の数字はかかった時間)と描画の時刻

ローダーを待っているあいだは、HydrateFallback に渡した Loading コンポーネントが表示されました。ページを開いてから 100ミリ秒後の画面は「最初の読み込み中…」だけでした。HydrateFallback は、最初にページを開いたときの読み込みのあいだに表示するものです。

ポイント

データを読み込み終える前に、CatPage や Meals が描画されることはありませんでした。コンポーネントの中で「データがまだないとき」の分岐を書かずに済みます。

ページを移動するときは、前の画面のまま待つ

次に、/ を開いてから「たま」のリンクを押し、/cats/1 へ移動しました。押してから約200ミリ秒後には、URL はまだ / のままで、前の画面(「猫を選んでください」)が表示されていました。新しいページは、ローダーが終わってから表示されます。

text
  383ms router: state=loading location=/
  383ms catLoader(1): 開始
  390ms Root を描画(navigation.state = loading)
  983ms catLoader(1): 終了
  984ms router: state=idle location=/cats/1
  985ms Root を描画(navigation.state = idle)
  990ms CatPage を描画(たま)

待っているあいだに何も変わらないと、押した人に、リンクが効いたことが伝わりません。読み込み中の表示(Pending UI)についてのガイドでは、useNavigation で読み込み中かどうかを調べる方法が紹介されています(データモードでも同じだと、データモード向けのページに書かれています)。検証では、一覧のコンポーネントに次の表示を入れました。

tsx
import { Link, Outlet, useLoaderData, useNavigation } from 'react-router';

export function Root() {
  const { cats } = useLoaderData<typeof rootLoader>();
  const navigation = useNavigation();
  return (
    <div>
      <nav>
        {cats.map((cat) => (
          <Link key={cat.id} to={`/cats/${cat.id}`}>
            {cat.name}
          </Link>
        ))}
      </nav>
      {navigation.state === 'loading' && <p className="pending">読み込み中…</p>}
      <Outlet />
    </div>
  );
}

リンクを押すと navigation.state が loading になり、前の画面の上に「読み込み中…」が出ました。

検証環境のChromiumで、上にリンク「たま」「くろ」「しろ」「いない猫」「壊れたページ」が並び、その下にピンクの帯「読み込み中…」、さらに下に前の画面の「猫を選んでください」が表示された画面
「たま」を押してから約200ミリ秒後。前の画面のまま「読み込み中…」が出ている

このとき、一覧の rootLoader は呼び直されていません。続けて「ごはんの記録」を押して /cats/1/meals へ移動したときも、呼ばれたのは mealsLoader だけでした。ルートオブジェクトの説明によると、データモードでは、ローダーが呼び直されるのは、そのルートの URL パラメーターが変わったとき、クエリ文字列が変わったとき、アクションがエラーではないステータスコードを返したあとの3つです。移動先で新しく表示されるルートのローダーは、そのつど呼ばれます。

見つからないときは 404 を投げ、ErrorBoundary で表示する

ローダーの中でデータが見つからないときは、data() でステータスコードを付けて投げます。エラーの扱いのガイドでも、404 はこの書き方が紹介されています。

tsx
import { data } from 'react-router';

export async function catLoader({ params, request }: LoaderFunctionArgs) {
  const cat = await fetchCat(params.catId!, request.signal);
  if (!cat) {
    throw data('猫が見つかりません', { status: 404 });
  }
  return { cat };
}

投げたものは、ルートの ErrorBoundary に渡したコンポーネントで、useRouteError を使って受け取ります。data() で投げたものは isRouteErrorResponse で見分けられ、status と data が入っています。

tsx
import { isRouteErrorResponse, useRouteError } from 'react-router';

export function ErrorPage() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return (
      <p role="alert">
        {error.status}:{error.data}
      </p>
    );
  }
  const message = error instanceof Error ? error.message : '不明なエラー';
  return <p role="alert">エラー:{message}</p>;
}

検証で /cats/99(いない猫)を開くと、「404:猫が見つかりません」と表示されました。上のリンクの並びは残っています。猫のページのルートに付けた ErrorBoundary がエラーを表示し、一覧のルートはそのまま描画されたためです。

ローダーの中で throw new Error('データの読み込みに失敗しました') としたルートを開いたときも、投げたエラーは同じ ErrorPage に渡り、「エラー:データの読み込みに失敗しました」と表示されました。

移動の途中で別のリンクを押すと、前の読み込みは中止される

ローダーは引数で request を受け取ります。上の catLoader では、request.signal を fetchCat に渡していました。検証用の fetchCat は、この signal が中止されたら待つのをやめて、ログを残すように作ってあります。

「くろ」を押し、読み込みが終わる前(約100ミリ秒後)に「しろ」を押しました。

text
  389ms catLoader(2): 開始
  396ms Root を描画(navigation.state = loading)
  519ms fetchCat(2): 中止された(signal.aborted = true)
  519ms router: state=loading location=/
  519ms catLoader(3): 開始
  529ms Root を描画(navigation.state = loading)
 1120ms catLoader(3): 終了
 1120ms router: state=idle location=/cats/3
 1121ms Root を描画(navigation.state = idle)
 1122ms CatPage を描画(しろ)

「しろ」を押した時点で、「くろ」の読み込みの signal が中止され、「くろ」のページは一度も描画されませんでした。最後に表示されたのは「しろ」のページです。操作が重なったときの扱いについての公式の説明にも、移動の途中で別の移動が始まると、前の移動のリクエストを中止すると書かれています。実際のアプリでは、公式の例のように fetch に signal: request.signal を渡しておけば、中止されたときにブラウザの通信も打ち切られます。検証では fetch は使っておらず、この部分は試していません。

注意

同じ説明によると、ブラウザ側で中止しても、すでに送ったリクエストはサーバーに届きます。ブラウザ側の中止で解放されるのはブラウザの資源だけで、リクエストがサーバーに届くのは止められません。

ローダーに読み込みを任せ、コンポーネントは表示に専念する

React Router のデータモードでは、ルートに loader を書き、コンポーネントで useLoaderData<typeof loader>() を呼ぶだけで、表示する前にデータを読み込めます。検証では、入れ子のルートのローダーは同時に始まり、すべて終わってから描画されました。移動中は前の画面のまま待つので、useNavigation で読み込み中の表示を出します。見つからないときは data() で 404 を投げて ErrorBoundary で表示し、検証では、途中で別のページに移ると、前の読み込みの request.signal が中止されました。公式の説明では、fetch に signal を渡しておけば通信も打ち切られます(この部分は検証していません)。

​Posted by 86