ReactでNavigation APIを使ってページを切り替える方法

​​React​flushSync,Navigation API,Playwright,TypeScript

Navigation API の navigate イベントを使うと、ふつうの <a href="/items/3"> を書いたまま、リンクの移動を JavaScript で受け止められます。ページ全体を読み込み直さずに、React の表示だけを切り替えられます。ブラウザの「戻る」「進む」も、同じイベントに届きます。

React で書くときに気をつけることが2つありました。1つは、新しいページの描画を flushSync で handler の中に収めることです。もう1つは、データの取得に e.signal を渡すことです。どちらも、守らないと実際に表示がおかしくなりました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 を使い、.tsx を tsc で型チェックしてから esbuild でビルドしました。ブラウザは Playwright 1.63.0 で動かした Chromium 153(ヘッドレス)だけです。Firefox、Safari、実機のスマートフォン、POST で送るフォーム、iframe の中のページ、React Router などのルーターとの組み合わせは試していません。

どのブラウザで使えるか

MDN の互換性データでは、Navigation は Chrome と Edge が102から、Firefox が147から、Safari が26.2から対応しています。

これより古いブラウザでは window.navigation がありません。そのときは何もせず、リンクをふつうのページ移動に任せます。そのため、サーバーはどのパスを開かれても同じ HTML を返すようにしておきます(後の節で確かめます)。

navigate イベントでリンクの移動を受け止める

検証用に、記事の一覧(/)、記事(/items/1 など)、「このサイトについて」(/about)の3種類のページを作りました。ページのデータは、サーバーの /api/<パス> から JSON で受け取ります。

ts
// api.ts
export type PageData =
  | { kind: 'list'; items: { id: number; title: string }[] }
  | { kind: 'item'; id: number; title: string; body: string }
  | { kind: 'about' }
  | { kind: 'notfound' };

export type Page = { path: string; data: PageData };

export async function loadPage(path: string, signal?: AbortSignal): Promise<Page> {
  const res = await fetch(`/api${path}`, { signal });
  const data = (await res.json()) as PageData;
  return { path, data };
}

完成形の App です。リンクは <a href> のままで、専用の Link コンポーネントはありません。PageView は、page.data.kind に応じて一覧・記事などを描画するだけのコンポーネントです。

tsx
// App.tsx
import { useEffect, useState } from 'react';
import { flushSync } from 'react-dom';
import { loadPage, type Page } from './api';
import { PageView } from './PageView';

export function App() {
  const [page, setPage] = useState<Page | null>(null);
  const [pending, setPending] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    // 最初の表示では navigate イベントが起きないので、ここで読み込む
    loadPage(location.pathname, controller.signal).then(setPage, () => {});

    // Navigation API がないブラウザでは、リンクは普通のページ移動になる
    if (!window.navigation) return () => controller.abort();

    const onNavigate = (e: NavigateEvent) => {
      // 別のオリジン、#だけの移動、ダウンロードは、ブラウザに任せる
      if (!e.canIntercept || e.hashChange || e.downloadRequest !== null) return;

      const url = new URL(e.destination.url);
      e.intercept({
        async handler() {
          setPending(true);
          const next = await loadPage(url.pathname, e.signal);
          // handler が終わる前に、新しいページの描画を済ませる
          flushSync(() => setPage(next));
        },
      });
    };
    const onDone = () => setPending(false);

    navigation.addEventListener('navigate', onNavigate);
    navigation.addEventListener('navigatesuccess', onDone);
    navigation.addEventListener('navigateerror', onDone);
    return () => {
      controller.abort();
      navigation.removeEventListener('navigate', onNavigate);
      navigation.removeEventListener('navigatesuccess', onDone);
      navigation.removeEventListener('navigateerror', onDone);
    };
  }, []);

  return (
    <>
      <nav>
        <a href="/">一覧</a> <a href="/about">このサイトについて</a>{' '}
        <a href="https://example.com/">外部サイト</a>{' '}
        <a href="/files/memo.txt" download>メモをダウンロード</a>{' '}
        <a href="#bottom">ページの下へ</a>
      </nav>
      <p id="status" style={{ position: 'fixed', top: 0, right: 8 }}>{pending ? '読み込み中…' : ''}</p>
      <main>{page ? <PageView page={page} /> : <p>読み込み中…</p>}</main>
      <p id="bottom">ページの下</p>
    </>
  );
}

navigate イベントの中で e.intercept() を呼ぶと、その移動はページを読み込み直さない移動に変わり、handler に書いた処理が実行されます。TypeScript 7.0.2 では、NavigateEvent や navigation の型が DOM の型定義に入っていて、型を自分で書き足さなくても tsc が通りました。

一覧から記事3、「このサイトについて」とリンクをたどり、戻る・進むを押したところ、表示はそのたびに切り替わりました。そのあいだ、ブラウザが HTML を取りに行ったのは最初の / の1回だけです。navigation.entries() には /、/items/3、/about の3件が並び、戻る・進むでもページは読み込み直されませんでした。

受け止めない移動を先に外す

すべての移動を intercept() してよいわけではありません。完成形では、次の3つを先に外しています。

e.canIntercept が false の移動は、受け止められません。MDN の canIntercept の説明によると、別のオリジンへの移動などがこれに当たります。検証でも、https://example.com/ へのリンクでは canIntercept が false になり、ブラウザがそのまま外部のページを開きました。

e.hashChange が true の移動は、同じページの中の #bottom へのリンクのような移動です。これを受け止めずにブラウザに任せると、一覧のページで「ページの下へ」を押したとき、データを読み込み直さずに下までスクロールしました。

ダウンロードのリンクを外す条件は、e.downloadRequest !== null と書きます。MDN の downloadRequest の説明によると、downloadRequest はダウンロードの移動ならファイル名、そうでなければ null です。download 属性に値を書かなかったリンクでは、検証すると downloadRequest は空文字列でした。空文字列は if の条件では偽として扱われるので、if (e.downloadRequest) と書くと、このリンクをダウンロードとして外せません。

URL は handler より先に変わる

リンクを押してからページが切り替わるまでの6つの段階を、上から下へ並べた図。リンクのクリック、navigate イベント、URL の変更、handler の実行、flushSync での描画、handler の Promise の完了の順。URL が先に変わる段階をピンクで示している
リンクを押してからページが切り替わるまでの順番。ピンクの段階で、URLだけが先に変わる

handler が呼ばれるのは、URL が変わったあとです。MDN の intercept() の説明にも、handler は currentEntry が更新されたあとに実行されると書かれています。

検証用のサーバーは、記事1のデータだけ1.5秒遅らせて返すようにしました。一覧で記事1のリンクを押して300ミリ秒後に見ると、URL はもう /items/1 なのに、画面には一覧が出たままでした。そのため、handler の最初で setPending(true) にして「読み込み中…」を出しています。データが届いて記事1が出たあとは、「読み込み中…」の表示は消えていました。

「読み込み中…」を消すのは、handler の中ではなく navigatesuccess と navigateerror のイベントです。MDN の Navigation API の説明にあるとおり、handler の Promise が成功すると navigatesuccess、失敗すると navigateerror が起きます。検証でも、記事1の取得中に別のリンクを押したときは、記事1の移動について navigateerror が起きました。なお、完成形では「読み込み中…」を position: fixed で画面の右上に重ねていて、次の節の測定もこの置き方で行いました。

handler の中で flushSync を使い、描画を済ませておく

MDN の intercept() の説明によると、戻る・進むで移動したとき、ブラウザはスクロール位置を戻す処理を、handler の Promise が完了するまで待ちます。新しく開いたページは、いちばん上から表示されます。

検証では、一覧を1,500ピクセルまでスクロールしてから記事30を開き、「戻る」を押して、1.2秒後のスクロール位置を見ました。setPage の呼び方だけを変えて比べた結果です。「読み込み中…」の表示は、どの書き方でも完成形と同じく position: fixed で画面の右上に重ねています。

handler の中の書き方 一覧のスクロール位置が戻った回数
setPage(next) をそのまま呼ぶ 20回中12回(8回はいちばん上)
handler が終わってから300ミリ秒後に setPage(next) 5回中0回
flushSync(() => setPage(next))(完成形) 10回中10回

setPage をそのまま呼んだ場合、navigatesuccess の時点では、どの回も画面はまだ記事30のままでした。React がこの更新を描画するのは、handler が終わったあとです。位置が戻ったのは20回中12回で、残りの8回は一覧がいちばん上から表示されました。描画をさらに遅らせた書き方では、5回とも戻りませんでした。

flushSync で包むと、React はその場で DOM を更新します。navigatesuccess の時点で画面はもう一覧になっていて、10回ともスクロール位置が1,500に戻りました。react.dev では flushSync はまれにしか使わないものとされていますが、ブラウザの API と組み合わせるときには使ってよい場面として挙げられています。ここでは、移動1回につき1回だけ呼んでいます。

途中で別のリンクを押したときは e.signal で取得を止める

遅い記事1の取得中に、記事2のリンクを押した場合を試しました。MDN の signal の説明にあるとおり、e.signal は、ほかの移動が始まって今の移動が取り消されると中止の状態になります。

fetch に e.signal を渡さなかった場合、いったん記事2が表示されたあと、遅れて届いた記事1のデータで画面が上書きされました。URL は /items/2 なのに、見出しは「記事 1」です。

e.signal を渡した完成形では、記事2を押した時点で記事1の fetch が中止されます。記事1の handler は AbortError で終わり、setPage まで進みません。2秒待っても、表示は記事2のままでした。

なお、取り消された記事1の移動も、履歴には残りました。navigation.entries() は /、/items/1、/items/2 の3件です。記事2から「戻る」を押すと、記事1のページになります。

最初の表示と、サーバーが返す HTML

MDN の Navigation API の説明にあるとおり、ページを最初に読み込んだときには navigate イベントは起きません。そのため、完成形では useEffect の最初で、location.pathname のページを自分で読み込んでいます。

アドレスバーに /items/5 を直接入れて開く場合も考えます。検証用のサーバーは、/api/ で始まるパスや JavaScript などのファイルを除き、どのパスでも同じ HTML を返すようにしました。/items/5 を直接開くと、記事5が表示されました。

Navigation API がないブラウザの代わりに、window.navigation を undefined にして試しました。記事3のリンクを押すと、ブラウザが /items/3 の HTML を取りに行き、ページ全体が読み込み直されました。表示は記事3になり、リンクとしては問題なく動きます。

もう1つ、intercept() で移動したあと、フォーカスは body に移っていました。記事3のリンクにフォーカスがある状態で押すと、記事3が出たあとのフォーカスは body です。MDN の intercept() の説明によると、focusReset の既定値では、autofocus の付いた要素があればそこに、なければ body にフォーカスを移します。

​Posted by 86