Next.js でよくある一覧 + 詳細画面を作る

あの日見たパターンの名前を僕たちはまだ知らない.

よくある一覧 + 詳細画面を作りたい

例えば TODO アプリで, /todo にアクセスしたらタスクの一覧を, /todo/42 にアクセスしたら一覧は表示したまま ID = 42 のタスクの詳細を表示する, というよくあるパターンの画面を作りたい. 世の中の実例としては Asana や, URL の形は異なりますが GitHub の Projects なんかがこういう感じですね.

TODO アプリのスクリーンショット. /todo ではタスクの一覧が表示され, /todo/42 ではタスクの一覧と詳細が表示されている
/todo で一覧, /todo/42 で一覧 + 詳細

技術的には要するに SPA なのでやればできるはずなんですが, これを Next.js (App Router) 上で作るにはどうしたら良いかという話をします.

素朴な実装は微妙

Next.js はファイルシステムベースのルーティングを行います. ということで最も素朴には以下のようにファイルを配置すれば目的のものが実装できそうです.

todo
|-- page.tsx     : /todo に対応するページ (一覧)
\-- [id]
    \-- page.tsx : /todo/42 などに対応するページ (一覧 + 詳細)

これはぱっと見ではうまく動くようにも見えるのですが, 実際にはパスが変わるたびに一覧部分のコンポーネントが再マウントされ, スクロール位置や内部状態がリセットされてしまって使い勝手に難があります.

ちなみに以下のように optional catch-all segments を使っても同様の理由で微妙です.

todo
\-- [[...slug]]
    \-- page.tsx : パスに応じて一覧もしくは一覧 + 詳細

layout.tsx を使った実装

layout.tsx はパスが変化しても再マウントされないため, この中で一覧を描画し, page.tsx には詳細の表示のみを任せるという形にすれば, 上記の素朴な実装の問題を解消できます.

todo
|-- layout.tsx   : 画面全体のレイアウト + 一覧を表示
|-- page.tsx     : 何も表示しない
\-- [id]
    \-- page.tsx : 詳細をダイアログ等で表示

これ自体はシンプルで良いのですが, 強いて言えばファイル構成から各ファイルの役割が読み取りづらいのと, layout.tsx が複数の役割を持ってしまっていてやや fat な印象がありますね.

Parallel Routes

ところで Next.js には Parallel Routes という機能があって, これを使うと一つのパスに対して複数のページコンポーネントを描画できます.

例えば以下のようにファイルを配置すると, /todo にアクセスされたときに todo/page.tsxtodo/@foo/page.tsx の両方が描画されます.

todo
|-- layout.tsx
|-- page.tsx
\-- @foo
    \-- page.tsx

描画された各コンポーネントの画面への配置は layout.tsx で行います. 通常であればページコンポーネントの描画結果は children という名前で受け取れますが, ここではそれに加えて @foo 以下のページコンポーネントの描画結果が foo という名前で受け取れます. 最もシンプルには以下のようなイメージです.

// layout.tsx
export default function Layout({ children, foo }: LayoutProps<"/todo">) {
  return (
    <>
      {children}
      {foo}
    </>
  );
}

Parallel Routes を使う場合, パスに対応する page.tsx はどこかに 1 つでもあればよいです (1 つもなければそのパスは Not Found になります). page.tsx がない場合は, もし default.tsx があればそれがハードナビゲーション時に代わりに描画されます.

例えば以下のファイル配置では @foo 以下に page.tsx はありませんが, /todo/todo/42 などにアクセスしてもエラーになったりはせず, default.tsx の内容が表示されます.

todo
|-- layout.tsx
|-- page.tsx
|-- [id]
|   \-- page.tsx
\-- @foo
    \-- default.tsx

先ほど default.tsx が代わりに使われるのが「ハードナビゲーション時」という説明をしましたが, ここがやや癖のある部分で, page.tsx のないパスにアクセスされた場合の挙動は, ハードナビゲーション時とソフトナビゲーション時で以下のように異なっています.

  • ハードナビゲーション時: deafult.tsx があればそれを描画, なければエラー
  • ソフトナビゲーション時: 元々描画していたコンポーネントがそのまま描画され続ける (再マウントもされない)

Parallel Routes を使った実装

話を一覧 + 詳細画面の実装に戻すと, 上記の通り Parallel Routes にはやや癖がありますが, これを使うことで各ファイルの役割を明示しつつ, 一覧を表示する役割も layout.tsx から分離できます.

具体的にはファイルを以下のように配置します. メイン部分 (以下 @dialog に対して @children と呼びます) の側で default.tsx を使っているのが面白いところですね.

todo
|-- layout.tsx       : 画面全体のレイアウト
|-- default.tsx      : 一覧を表示
\-- @dialog
    |-- page.tsx     : 何も表示しない
    \-- [id]
        \-- page.tsx : 詳細をダイアログ等で表示

順番に挙動を見ていきましょう. まずはハードナビゲーション時は以下のようになります.

  • /todo:
    • @children: page.tsx がないので default.tsx を描画 → 一覧を表示
    • @dialog: page.tsx があるので描画 → 何も表示しない
  • /todo/42:
    • @children: page.tsx がないので default.tsx を描画 → 一覧を表示
    • @dialog: page.tsx があるので描画 → 詳細をダイアログ等で表示

続いてソフトナビゲーション時は以下の通りです. 先ほど説明した通り, page.tsx がない場合はコンポーネントが再マウントもされずそのまま使われるので, 一覧部分のスクロール位置や内部状態がリセットされたりすることはありません.

  • /todo:
    • @children: page.tsx がないので元のまま → 一覧を表示したまま
    • @dialog: page.tsx があるので描画 → 何も表示しない
  • /todo/42:
    • @children: page.tsx がないので元のまま → 一覧を表示したまま
    • @dialog: page.tsx があるので描画 → 詳細をダイアログ等で表示

というわけで目的のものが実装できました. @dialog のように名前をつけたことで各ファイルの役割もわかりやすくなっており, layout.tsx の役割も本来の役割である画面全体のレイアウトのみになっています. 欠点としては, やはり Parallel Routes 自体の挙動がちょっとわかりづらいことでしょうか.

おまけ: Intercepting Routes

Next.js にはさらに Parallel Routes を応用した Intercepting Routes という機能もあり, これを使うとソフトナビゲーションをその名の通り遮って, 通常 (ハードナビゲーション) 時とは異なるページを表示できます.

例えば以下のようにファイルを配置すると, /todo から /todo/42 にソフトナビゲーションした時に, 一覧の表示を維持したまま詳細をダイアログ等で表示できます. /todo/42 を直接開いたりページをリロードするなどしてハードナビゲーションすると, 詳細は全画面で表示されます.

todo
|-- layout.tsx       : 画面全体のレイアウト
|-- page.tsx         : 一覧を表示
|-- [id]
|   \-- page.tsx     : 詳細を全画面で表示
\-- @dialog
    |-- default.tsx  : 何も表示しない
    \-- (.)[id]
        \-- page.tsx : 詳細をダイアログ等で表示

Next.js で一覧 + 詳細表示をする方法を探すと真っ先に Intercepting Routes を使った方法が見つかりがちですが, これはハードナビゲーション時とソフトナビゲーション時で挙動を変えたい場合に使いましょう. 詳細は常にダイアログで表示したいといったように, 挙動をナビゲーションの方法によらず統一したい場合は Intercepting Routes は必要ありません.

まとめ

よくある一覧 + 詳細画面の実装方法をいくつか見てきました.

  • 素朴に page.tsx だけを使った実装では, 再マウントでスクロール位置や内部状態がリセットされてしまうため注意
  • 一覧を layout.tsx で表示する方法はシンプルだが, ファイル構成から各ファイルの役割が読み取りづらいのと, layout.tsx の役割がやや多くなる
  • Parallel Routes は挙動に癖があるが, これを使うと各ファイルの役割を明示しつつ, 一覧の表示の役割も layout.tsx から分離できる
  • Intercepting Routes はハードナビゲーション時とソフトナビゲーション時で挙動を変えたいときに使いましょう