Read OSS

クライアントサイドルーター:ナビゲーション、キャッシュ、状態管理

上級

前提知識

  • 記事1〜3:アーキテクチャ、サーバーライフサイクル、App Routerのレンダリング
  • ReactのcontextとuseReducerのパターン
  • ブラウザのHistory API(pushState、popstate)
  • 記事3で解説したRSC Flightペイロードフォーマットの理解

クライアントサイドルーター:ナビゲーション、キャッシュ、状態管理

Next.js App Routerアプリケーションでリンクをクリックしても、ページはリロードされません。代わりに、高度なクライアントサイドのステートマシンがサーバーからRSC Flightペイロードを取得し、Reactエレメントのキャッシュツリーに適用した上で、変更のあったセグメントだけを更新します。変更のないセグメントは、レイアウトの状態、スクロール位置、Reactコンポーネントの状態をそのまま保持します。本記事では、この仕組みを詳しく見ていきます。

AppRouter:ルートコンポーネント

AppRouter コンポーネントは app-router.tsx に定義されており、App Routerのクライアントサイドツリーのルートを担います。このコンポーネントは次のものを初期化します。

  1. ルーターreducer(ステートマシン)
  2. Historyイベントリスナー(popstate)
  3. ナビゲーションhookを支えるContext provider
  4. グローバルなエラーハンドリングのためのError boundary
flowchart TD
    AppRouter["AppRouter Component"] --> ActionQueue["useActionQueue()\n(reducer state)"]
    AppRouter --> History["HistoryUpdater\n(pushState/popstate)"]
    AppRouter --> Providers["Context Providers"]
    AppRouter --> ErrorBoundary["RootErrorBoundary"]

    Providers --> AppRouterContext["AppRouterContext\n(provides: router instance)"]
    Providers --> LayoutRouterContext["LayoutRouterContext\n(provides: segment tree)"]
    Providers --> PathnameContext["PathnameContext"]
    Providers --> SearchParamsContext["SearchParamsContext"]

    ActionQueue --> Reducer["clientReducer()\n(router-reducer.ts)"]

HistoryUpdater コンポーネントは特筆すべき存在です。その役割はただひとつ、状態の変化に応じて window.history.pushState() または replaceState() を呼び出すことだけです。これをReactコンポーネントとして実装することで、Reactのコミットフェーズ中に実行され、HistoryのアップデートがレンダリングされたUIと確実に同期します。

初期状態はサーバーレンダリングされたページから取得します。サーバーは初期の FlightRouterState ツリーと CacheNode ツリーを(記事3で見たように、インライン化された <script> タグを通じて)送信します。クライアントサイドルーターはこの初期状態からハイドレーションを行い、以降のすべての更新を管理します。

ルーターReducerステートマシン

クライアントサイドルーターの核心は clientReducer 関数です。現在の状態とアクションを受け取り、新しい状態を返す純粋関数という、Reduxに似たパターンを採用しています。

function clientReducer(
  state: ReadonlyReducerState,
  action: ReducerActions
): ReducerState {
  switch (action.type) {
    case ACTION_NAVIGATE:
      return navigateReducer(state, action)
    case ACTION_SERVER_PATCH:
      return serverPatchReducer(state, action)
    case ACTION_RESTORE:
      return restoreReducer(state, action)
    case ACTION_REFRESH:
      return refreshReducer(state, action)
    case ACTION_HMR_REFRESH:
      return hmrRefreshReducer(state)
    case ACTION_SERVER_ACTION:
      return serverActionReducer(state, action)
    default:
      throw new Error('Unknown action')
  }
}

各アクションは、異なるナビゲーションシナリオを表します。

stateDiagram-v2
    [*] --> Idle: Initial render

    Idle --> Navigate: ACTION_NAVIGATE\n(Link click, router.push)
    Idle --> Restore: ACTION_RESTORE\n(Browser back/forward)
    Idle --> Refresh: ACTION_REFRESH\n(router.refresh())
    Idle --> ServerAction: ACTION_SERVER_ACTION\n('use server' call)
    Idle --> HMRRefresh: ACTION_HMR_REFRESH\n(Dev hot reload)

    Navigate --> FetchFlight: Fetch RSC payload
    FetchFlight --> ServerPatch: ACTION_SERVER_PATCH\n(Apply response)
    ServerPatch --> Idle: Update tree + cache

    Restore --> Idle: Restore from history state

    Refresh --> FetchFlight
    ServerAction --> FetchFlight

    HMRRefresh --> Idle: Force re-render

ここには、微妙ながら重要なアーキテクチャ上の選択があります。reducerはサーバー向けにも(serverReducer として)コンパイルされますが、その実装は何もしない(no-op)関数です。これにより、同じルーターコンポーネントのコードをSSR中にもエラーなく利用でき、実際の状態管理はブラウザでのみ有効になります。60行目のコメントには、「クライアントreducerはサーバーで実行しないため、ツリーシェイキングを改善するためにnoop関数を使用する」と記されています。

ナビゲーション時のRSCペイロード取得

navigateReducer が実行されると、新しいルートのRSCペイロードを取得するためのサーバーリクエストが発行されます。この処理を担うのが fetch-server-response.ts モジュールです。

sequenceDiagram
    participant Router as Client Router
    participant Fetch as fetch-server-response
    participant Server as Next.js Server
    participant Flight as React Flight Client

    Router->>Fetch: Navigate to /dashboard
    Fetch->>Server: GET /dashboard
    Note over Fetch,Server: Headers: RSC: 1, Next-Router-State-Tree: [current tree], Next-Url: /dashboard
    Server-->>Fetch: Flight payload (binary stream)
    Fetch->>Flight: createFromFetch(response)
    Flight-->>Router: NavigationFlightResponse
    Router->>Router: Apply to CacheNode tree

リクエストには、サーバーがレスポンスの内容を決定するためのいくつかの特殊なヘッダーが含まれます。

  • RSC: 1 — RSCリクエストであることを示します(HTMLではなくFlightを返す)
  • Next-Router-State-Tree — クライアントの現在の FlightRouterState。サーバーがdiffを計算するために使用します
  • Next-Url — 現在のURL。middlewareの評価に使用します
  • Next-Router-Prefetch — プリフェッチリクエストに設定します。レイアウトデータのみを返します

レスポンスは react-server-dom-webpack/client が提供するReactの createFromFetch() APIを使用して解析されます。この関数はバイナリのFlightストリームを読み込み、クライアントサイドのReactインスタンスがレンダリングできるReactエレメントツリーを再構築します。

ヒント: FlightペイロードはJSONではありません。Reactエレメント、クライアント参照、Promise、ストリーミングデータを表現できるReact独自のバイナリフォーマットです。response.json() では読み取れません。デコードするには createFromFetch または createFromReadableStream 関数を使う以外に方法はありません。

レイアウトの保持とCacheNodeツリー

App Routerでもっとも目に見える形で体感できる機能が、レイアウトの保持です。/dashboard/analytics から /dashboard/settings へナビゲートしても、dashboardレイアウトの状態(スクロール位置、フォームの入力値、コンポーネントの状態)はそのまま維持されます。これを実現しているのが CacheNode ツリーです。

export type CacheNode = {
  rsc: React.ReactNode        // Full RSC data
  prefetchRsc: React.ReactNode // Prefetched static shell
  prefetchHead: HeadData | null
  head: HeadData
  slots: Record<string, CacheNode> | null
  // ...
}
graph TD
    Root["CacheNode: /"] --> Dashboard["CacheNode: dashboard"]
    Root --> Settings["CacheNode: settings"]

    Dashboard --> Analytics["CacheNode: analytics\n(page content)"]
    Dashboard --> DashSettings["CacheNode: settings\n(page content)"]

    style Analytics fill:#9f9,stroke:#333
    style DashSettings fill:#ff9,stroke:#333

    classDef active fill:#9f9
    classDef navigating fill:#ff9

layout-router.tsx コンポーネントは、ネストされたレイアウトツリーの各階層のレンダリングを担います。contextを通じてそのセグメントの CacheNode を受け取り、rsc コンテンツをレンダリングします。完全なデータがまだ届いていない場合は、Reactの useDeferredValue を使って prefetchRsc にフォールバックします。

ナビゲーションが発生すると、ルーターは現在の FlightRouterState ツリーと次の FlightRouterState ツリーを比較します。変更のないセグメントは既存の CacheNode をそのまま使い続けるため、Reactはそれらを再レンダリングしません。変更のあったセグメントだけが、Flightペイロードから取得した最新のRSCデータで CacheNode エントリを更新します。これはルートセグメントレベルでの構造的共有(structural sharing)です。

CacheNodeslots フィールドは、parallel routeのキーをその子に対応付けます。たとえば dashboard セグメントには、children(デフォルトのスロット)と @modal(parallel route)のスロットがある場合があります。各スロットは独立してキャッシュツリーを管理します。

PPRプリフェッチのためのセグメントキャッシュ

segment-cache/ ディレクトリには、PPRが有効なルート向けのセグメント単位のキャッシュが実装されています。PPRが有効な場合、プリフェッチはルート全体のFlightデータをまとめて取得するのではなく、ルートセグメントを個別に取得します。

flowchart TD
    Link["<Link> visible\nin viewport"] --> Prefetch["prefetch()"]
    Prefetch --> SegmentCache["Segment Cache"]

    SegmentCache --> RootSegment["/ segment\n(static shell)"]
    SegmentCache --> DashSegment["/dashboard segment\n(static shell)"]
    SegmentCache --> PageSegment["/dashboard/analytics segment\n(may be dynamic)"]

    Navigate["User clicks link"] --> CheckCache["Check segment cache"]
    CheckCache --> Hit["Cache hit:\nServe static shell instantly"]
    CheckCache --> Miss["Cache miss:\nFetch dynamic data"]
    Hit --> Merge["Merge shells +\ndynamic data"]
    Miss --> Merge
    Merge --> Render["Render updated tree"]

セグメントキャッシュモジュールは複数のファイルで構成されています。

ファイル 役割
cache.ts コアキャッシュのデータ構造と操作
cache-key.ts ルートセグメントからのキャッシュキー生成
prefetch.ts プリフェッチのスケジューリングと実行
navigation.ts キャッシュを考慮したナビゲーションロジック
scheduler.ts 優先度ベースのプリフェッチスケジューリング
lru.ts メモリ管理のためのLRU削除

これはクライアントサイドルーターのなかでも特に高度な部分です。ルート全体をキャッシュするのではなく、セグメント単位でキャッシュすることで、レイアウトを共有する複数のページでPPRルートの静的な部分をまたいで再利用できます。動的なセグメントは必要に応じてオンデマンドで取得されます。

ナビゲーションHook:useRouter、usePathname、useSearchParams

公開されているナビゲーションAPIは、navigation.ts のReact contextを通じて実装されています。

flowchart TD
    AppRouter["AppRouter"] --> Ctx1["AppRouterContext\n(provides: router instance)"]
    AppRouter --> Ctx2["PathnameContext\n(provides: pathname string)"]
    AppRouter --> Ctx3["SearchParamsContext\n(provides: URLSearchParams)"]
    AppRouter --> Ctx4["PathParamsContext\n(provides: dynamic params)"]

    Ctx1 --> useRouter["useRouter()\nreturns: push, replace, refresh, back, forward, prefetch"]
    Ctx2 --> usePathname["usePathname()\nreturns: string"]
    Ctx3 --> useSearchParams["useSearchParams()\nreturns: ReadonlyURLSearchParams"]
    Ctx4 --> useParams["useParams()\nreturns: Params object"]

useRouter() hookは、app-router-instance.ts で生成されたルーターインスタンスを返します。このモジュールは publicAppRouterInstance を作成します。これは push()replace()refresh()back()prefetch() といったメソッドを持つオブジェクトで、各メソッドはルーターreducerにアクションをdispatchします。

navigation.ts で興味深いのは、デュアル環境対応のパターンです。サーバー(SSR)上で実行される場合、useSearchParamsuseParams はPPRの動的レンダリングのベイルアウトをトリガーする必要があります。これは、useDynamicRouteParamsuseDynamicSearchParams をサーバーサイドでのみ条件付きでimportすることで対応しています。

const useDynamicRouteParams =
  typeof window === 'undefined'
    ? require('../../server/app-render/dynamic-rendering').useDynamicRouteParams
    : undefined

typeof window チェックを使ってサーバーモジュールを条件付きでrequireするこのパターンは、クライアントコード全体で使われています。サーバー専用のコードがブラウザバンドルからツリーシェイキングされることを保証するためです。

ヒント: App Routerの useRouter() が返すオブジェクトは、Pages Routerの next/router から得られる useRouter() とは異なります。APIも違えば、それぞれまったく別の状態管理システムで動作しています。Pages Routerから移行する場合は、混同しやすいポイントのひとつです。

ルーターインスタンスと命令的ナビゲーション

app-router-instance.ts モジュールは AppRouterActionQueue を生成します。これはReactの状態とreducerを橋渡しするdispatcherです。

export type AppRouterActionQueue = {
  state: AppRouterState
  dispatch: (payload: ReducerActions, setState: DispatchStatePromise) => void
  action: (state: AppRouterState, action: ReducerActions) => ReducerState
}

このキューはアクションのシリアライズを担います。複数のナビゲーションが素早く連続して発生した場合(たとえばユーザーが異なるリンクを次々とクリックした場合)、アクションはキューに入れられ、順番に処理されます。また、Reactの startTransition APIと統合されており、ナビゲーションの状態更新は緊急度の低いtransitionとして扱われ、ユーザー入力をブロックしません。

ルーターインスタンスの prefetch() メソッドはセグメントキャッシュシステムと連携しています。<Link> コンポーネントがビューポートに表示されると、prefetchWithSegmentCache() を通じてプリフェッチが開始されます。プリフェッチスケジューラーは、ビューポートの可視性とユーザーのインタラクションパターンに基づいて優先度を決定します。

次の記事について

これでApp Routerの完全なライフサイクルを追い終えました。サーバーサイドレンダリング(記事3)からクライアントサイドのナビゲーションと状態管理まで、一通りの流れを把握できました。次の記事では、ビルドシステムに目を向けます。next build がルートを探索し、コードを生成し、3つの独立したwebpackコンパイルを構成し、サーバー/クライアントのモジュールグラフを橋渡しするマニフェストを生成する仕組みを解説します。