React Routerで、ページを移るあいだの読み込み表示を出す方法

​​2026年11月14日​React​React Router,TypeScript

React Router のデータモードでは、リンクを押してから次のページのローダーが終わるまで、前の画面がそのまま表示されています。useNavigation と NavLink を使うと、そのあいだに「読み込み中」の表示を出せます。遅いデータだけを後回しにして、先にページを移ることもできます。

私は検証環境で、読み込みに約900ミリ秒かかる「たま」のページ、約80ミリ秒で終わる「お知らせ」のページ、中身の一部が遅い「日記」のページを作り、リンクを押してから画面がどう変わるかを記録しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153(Chrome for Testing 153.0.8010.12)です。使ったのは react-router 8.5.0、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。データはサーバーの代わりに、決まった時間だけ待ってから返す関数で用意しました。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作しています。createBrowserRouter を使うデータモードだけを試し、フレームワークモードとフォームの送信中の表示は試していません。

次の GIF は、この記事で作る画面を検証環境で動かし、「たま」と「日記」を順に押したところです。読み込みバーは150ミリ秒待ってから出す設定にし、「猫」のリンクには end を付けています(どちらも後の節で説明します)。

下の帯には、何回目のクリックかと、クリックからの経過時間を出しています。変わった表示は、その瞬間だけピンクの枠で囲んでいます。GIF の再生は実際より遅く、下の帯の時間が実際の経過です。

検証環境のChromiumの画面。上に「猫」「たま」「お知らせ」「日記」のリンクがある。1回目に「たま」を押すと、リンクが灰色の「たま…」になり、少しあとに上部に水色の帯「読み込み中:/cats/1」が出て、約900ミリ秒後に見出しが「たま」に変わる。2回目に「日記」を押すと、すぐに見出し「たまの日記」と灰色の「日記を読み込み中…」が出て、約1.2秒後に日記の2行に置き換わる
「たま」では前の画面のまま待ち、「日記」では先にページを移ってから中身を出す動き

「たま」では、前の画面のまま、リンクの色と上のバーで待っていることを伝えています。「日記」では、先にページを移り、遅い部分だけ「日記を読み込み中…」で待たせています。

画面の上に、読み込みバーを出す

読み込み中の表示(Pending UI)についてのガイドでは、アプリ全体の読み込み表示に useNavigation を使う方法が紹介されています。次のページへの移動が始まると、navigation.location に行き先が入ります。これが入っているあいだだけ、バーを出します。データモード向けのページによると、データモードでもフレームワークモードと同じ方法で書けます。

ガイドの例と同じように、バーはいちばん外側のルートのコンポーネント(Root)に置きます。Root はどのページでも描画されるので、どのページから移るときもバーを出せます(バーが出ることを確かめたのは、ホームから移る場合です)。検証に使ったコードから、記録と切り替えのための部分を省くと、次のようになります。「たま」のリンクの中身については、次の節で説明します。

tsx
// routes.tsx(一部)
import { NavLink, Outlet, useNavigation } from 'react-router';

function PendingBar() {
  const navigation = useNavigation();
  const isNavigating = Boolean(navigation.location);
  if (!isNavigating) return null;
  return (
    <div className="pending-bar" role="status">
      読み込み中:{navigation.location?.pathname}
    </div>
  );
}

export function Root() {
  return (
    <div>
      <PendingBar />
      <nav className="menu">
        <NavLink to="/cats">猫</NavLink>
        <NavLink to="/cats/1">
          {({ isPending }) => <>たま{isPending && <span className="spinner">…</span>}</>}
        </NavLink>
        <NavLink to="/news">お知らせ</NavLink>
        <NavLink to="/diary">日記</NavLink>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
tsx
// main.tsx(一部)
const router = createBrowserRouter([
  {
    path: '/',
    Component: Root,
    children: [
      { index: true, Component: Home },
      { path: 'cats', Component: CatIndex },
      { path: 'cats/:catId', loader: catLoader, Component: CatPage },
      { path: 'news', loader: newsLoader, Component: NewsPage },
      { path: 'diary', loader: diaryLoader, Component: DiaryPage },
    ],
  },
]);

ホームで「たま」のリンクを押したときの記録です。時刻はクリックからの経過です。「PendingBar を描画」の行は PendingBar を描画したときの useNavigation の値、「画面」の行は画面が変わったときの様子です。この記事の記録は、どれも必要な行だけを抜き出しています。

text
    0ms 「たま」をクリック
    7ms PendingBar を描画(state=loading、行き先=/cats/1、バー=表示)
   10ms [画面] URL=/ 画面=ホーム バー=読み込み中:/cats/1 猫のclass=pending たまのclass=pending たまの表示=たま… 日記=-
  907ms PendingBar を描画(state=idle、行き先=なし、バー=なし)
  910ms [画面] URL=/cats/1 画面=たま バー=なし 猫のclass=active たまのclass=active たまの表示=たま 日記=-

押してすぐに navigation.state が loading になり、navigation.location.pathname には行き先の /cats/1 が入りました。このとき、アドレスバーの URL はまだ / で、画面もホームのままです。約900ミリ秒後にローダーが終わると、URL と画面が同時に「たま」へ変わり、バーが消えました。

押したリンクだけ、文字を灰色にして「…」を付ける

ガイドでは、NavLink を使って、押したリンクの近くに読み込み表示を出す方法も紹介されています。NavLink の children、className、style に関数を渡すと、引数で isPending を受け取れます。上のコードの「たま」のリンクでは、children に関数を渡し、移動中だけ「…」を付けています。

また、className に関数を渡していなければ、移動中のリンクには自動で pending という class が付きます。検証では、CSS で .menu a.pending の文字を灰色にしました。「たま」を押すと、ローダーを待っているあいだ、pending の class で文字が灰色になり、children の関数で「…」が付いて、灰色の「たま…」になりました。

ただし、このとき「たま」だけでなく、/cats へのリンク(「猫」)にも pending が付きました。

text
   10ms [画面] URL=/ 画面=ホーム バー=読み込み中:/cats/1 猫のclass=pending たまのclass=pending たまの表示=たま… 日記=-

NavLink は、行き先のパスが to の下にあるときも、そのリンクを移動中とみなすためです。NavLink のリファレンスにある end の説明によると、end がないときは to の下のパスも一致とみなされ、end を付けると to と同じパスのときだけ一致します。これは active と pending の両方の判定に効きます。インストールした react-router 8.5.0 のコードでも、isPending は「行き先が to と同じ」か「end がなく、行き先が to + / で始まる」ときに true になっていました。

tsx
<NavLink to="/cats" end>
  猫
</NavLink>

end を付けると、「たま」を押したときに pending が付くのは「たま」だけになりました。

text
   10ms [画面] URL=/ 画面=ホーム バー=読み込み中:/cats/1 猫のclass=(なし) たまのclass=pending たまの表示=たま… 日記=-

end がないときは、「たま」のページを開いたあとも、「猫」のリンクに active が付いていました。

すぐ終わる移動では、バーを出さない

「お知らせ」のローダーは約80ミリ秒で終わります。ここまでのバーのままだと、押したときにバーが約80ミリ秒だけ出て消えます。

text
    0ms 「お知らせ」をクリック
   12ms [画面] URL=/ 画面=ホーム バー=読み込み中:/news 猫のclass=(なし) たまのclass=(なし) たまの表示=たま 日記=-
   90ms [画面] URL=/news 画面=お知らせ バー=なし 猫のclass=(なし) たまのclass=(なし) たまの表示=たま 日記=-

検証では、移動が150ミリ秒続いたときだけバーを出すようにしました。そのために、useEffect と setTimeout で小さなフックを作っています。

tsx
// useDelayedFlag.ts
import { useEffect, useState } from 'react';

// flag が true のまま delay ミリ秒たってから true を返す
export function useDelayedFlag(flag: boolean, delay: number) {
  const [shown, setShown] = useState(false);
  useEffect(() => {
    if (!flag) {
      setShown(false);
      return;
    }
    const id = setTimeout(() => setShown(true), delay);
    return () => clearTimeout(id);
  }, [flag, delay]);
  return flag && shown;
}
tsx
import { useDelayedFlag } from './useDelayedFlag';

function PendingBar() {
  const navigation = useNavigation();
  const isNavigating = Boolean(navigation.location);
  const show = useDelayedFlag(isNavigating, 150);
  if (!show) return null;
  return (
    <div className="pending-bar" role="status">
      読み込み中:{navigation.location?.pathname}
    </div>
  );
}

移動が150ミリ秒より早く終わると、flag が false に変わり、タイマーは取り消されます。最後に flag && shown を返しているので、移動が終わった瞬間にバーは消えます。

こうすると、「お知らせ」ではバーが一度も出ませんでした。「たま」では、押してから約170ミリ秒後にバーが出て、ページが変わるまで表示されました。150ミリ秒という値は、検証のために決めたものです。

「お知らせ」を押したとき:

text
    0ms 「お知らせ」をクリック
    7ms PendingBar を描画(state=loading、行き先=/news、バー=なし)
   88ms PendingBar を描画(state=idle、行き先=なし、バー=なし)
   92ms [画面] URL=/news 画面=お知らせ バー=なし 猫のclass=(なし) たまのclass=(なし) たまの表示=たま 日記=-

「たま」を押したとき(この記録では、「猫」のリンクに end を付けていません):

text
    0ms 「たま」をクリック
   13ms [画面] URL=/ 画面=ホーム バー=なし 猫のclass=pending たまのclass=pending たまの表示=たま… 日記=-
  167ms [画面] URL=/ 画面=ホーム バー=読み込み中:/cats/1 猫のclass=pending たまのclass=pending たまの表示=たま… 日記=-
  911ms [画面] URL=/cats/1 画面=たま バー=なし 猫のclass=active たまのclass=active たまの表示=たま 日記=-

リンクの pending の class は、待たずにすぐ付いています。押したことは、リンクの色ですぐに分かります。

遅いデータを待たずに、先にページを移る

全部のデータがそろうまで待つと、そのあいだは前の画面のままです。Suspense を使ったストリーミングのガイドにあるように、ローダーで遅いデータを await せずに Promise のまま返すと、await したデータ(この例ではタイトル)がそろった時点でページを移れます。遅い部分は、Await と Suspense で後から表示します。

tsx
import { Suspense } from 'react';
import { Await, useLoaderData } from 'react-router';

export async function diaryLoader() {
  // 日記の中身は待たずに Promise のまま返す
  const entries = fetchDiaryEntries();
  const title = await fetchDiaryTitle();
  return { title, entries };
}

export function DiaryPage() {
  const { title, entries } = useLoaderData<typeof diaryLoader>();
  return (
    <>
      <h1>{title}</h1>
      <Suspense fallback={<p className="skeleton">日記を読み込み中…</p>}>
        <Await resolve={entries}>
          {(list) => (
            <ul>
              {list.map((d) => (
                <li key={d.date}>
                  {d.date} {d.text}
                </li>
              ))}
            </ul>
          )}
        </Await>
      </Suspense>
    </>
  );
}

検証では、日記のタイトルは約100ミリ秒、中身は約1,200ミリ秒で返るようにしました。

text
    0ms 「日記」をクリック
  108ms PendingBar を描画(state=idle、行き先=なし、バー=なし)
  113ms [画面] URL=/diary 画面=たまの日記 バー=なし 猫のclass=(なし) たまのclass=(なし) たまの表示=たま 日記=読み込み中…
 1209ms [画面] URL=/diary 画面=たまの日記 バー=なし 猫のclass=(なし) たまのclass=(なし) たまの表示=たま 日記=中身

約110ミリ秒でタイトルがそろうと、navigation.state は idle に戻り、URL と画面が日記のページに変わりました。中身の場所には「日記を読み込み中…」が出て、約1,200ミリ秒で中身に置き換わりました。中身の読み込みはタイトルを待つ前に始めているので、合わせて1,300ミリ秒にはなっていません。

useNavigation で見ている移動は約110ミリ秒で終わっているため、150ミリ秒待つ設定の読み込みバーは出ませんでした。ページを移ったあとの読み込み表示は、Suspense の fallback が受け持ちます。

「たま」のように前の画面のまま待つか、「日記」のように先にページを移って遅い部分だけ後から出すかは、ローダーでどのデータを await するかで決まります。

Await の errorElement を使うと、Promise が失敗したときの表示も書けるとAwait のリファレンスに書かれています。この記事では、失敗する場合は試していません。

ポイント

ページ全体を待たせる読み込みは useNavigation と NavLink の isPending で、ページを移ったあとに届くデータは Suspense の fallback で表示します。どちらも、押したらすぐに何かが変わるようにするためのものです。

​Posted by 86