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 はハードナビゲーション時とソフトナビゲーション時で挙動を変えたいときに使いましょう

querySelector に型引数を指定しない

必要もないのに querySelectorquerySelectorAll の型引数を指定しないようにしましょう.

(この記事は AI レビュワーに「型引数を指定した方が型安全だ」と提案されたのに対する反論として作成しています.)

querySelector の型安全性

querySelectorquerySelectorAll の型定義は, 後述する要素型セレクターに関連する部分を除くと, 基本的には以下のようになっています.

querySelector<E extends Element = Element>(selectors: string): E | null;

querySelectorAll<E extends Element = Element>(selectors: string): NodeListOf<E>;

これはつまり型引数が Element を継承していれば何であっても受け入れて, その型の値を返すという意味です. 戻り値の型を任意に決められてしまうということは, こんなものには as と同程度の安全性しかありません.

// as を使った場合, 型が間違っていてもエラーにならない
const element = document.querySelector("a.foo") as HTMLButtonElement | null;
// element: HTMLButtonElement | null

// 型引数を使った場合も, 型が間違っていてもエラーにならない
const element = document.querySelector<HTMLButtonElement>("a.foo");
// element: HTMLButtonElement | null

as を使うと以下のようにより誤った記述 (| null を忘れている) をしてしまう可能性があるので型引数を指定した方が良いという主張 (参考) は間違ってはいませんが, これは as と比べてミスしづらいというだけであって, 両者がともに型安全でないことには変わりありません.

// null の可能性を無視していてより危険!
const element = document.querySelector("a.foo") as HTMLButtonElement;
// element: HTMLButtonElement

我々が唯一常に信用できるのは, 戻り値の型が少なくとも Element | null であるということだけです. そしてその範囲で使用する際には型引数の指定は必要ありません.

// セレクターがなんであれ戻り値の型は常に正しく型安全
const element = document.querySelector("a.foo");
// element: Element | null

ケーススタディ

セレクターが要素型セレクターのみの場合

セレクターが要素型セレクター (要素名) のみの場合は型定義に特別扱いがあり, 何もしなくても要素固有の型が推論されるようになっているので, 型引数を指定する必要はありません.

const element = document.querySelector("a");
// element: HTMLAnchorElement | null

むしろ指定しようとすると誤った型を指定してしまう可能性があるので, 指定しないようにしましょう. 意外にも以下はエラーになりません.

const element = document.querySelector<HTMLButtonElement>("a");
// element: HTMLButtonElement | null

セレクターから要素の型が明らかな場合

要素型セレクターが含まれるなどセレクターから要素の型が明らかに読み取れる場合であっても, 複合的なセレクターになっていたら要素固有の型は推論されません.

const element = document.querySelector("a.foo");
// element: Element | null

もし要素固有の型が必要であればランタイムで型検査をするか, 型安全性を諦めて型引数を指定することになります. この場合は型引数とセレクターがすぐ近くにあってその一致を確認しやすいため, 多くの場合は型引数を指定することが許容されるでしょう.

const element = document.querySelector<HTMLAnchroElement>("a.foo");
// element: HTMLAnchroElement | null

ただし一致を確認するのは型システムではなく人間なので, セレクターを変更する際には必ず合わせて型引数も変更するようにしましょう. あるいは人間でなくても linter で機械的に検査することもできるかもしれません.

また要素固有の型が必要でなく Element で十分であれば, そもそも型引数を指定する必要はありません. 指定しなくて済むなら指定しない方がミスが入り込む余地がないため安全です.

その他の理由で要素の型がほぼ明らかな場合

プロジェクト内の常識や周辺コードの知識などによって, 要素の型がほぼ明らかな場合もあります.

例えば Element を継承している型には HTMLElement だけでなく SVGElementMathMLElement もありますが, 常識的に HTML の要素しか登場しない場面であれば HTMLElement と決めつけてしまっても安全性は大きく損なわれません.

const element = document.querySelector<HTMLElement>(".foo");
// element: HTMLElement | null

また要素が同じファイルに記述されている場合のように, 見ての通りその型であって概ね同時に変更されることが期待される場合も, 型引数を指定してしまっても問題はないでしょう.

const element = document.querySelector<HTMLAnchorElement>(".foo");
// element: HTMLAnchorElement | null

とはいえやはりこの場合も, より詳細な型を必要とせず Element で十分であれば, 型引数を指定しないでいる方が断然良いです.

要素の型が明らかでない場合

Element よりも具体的な型が必要であればランタイムで型をチェックしましょう.

const element = document.querySelector(".foo");
// element: Element | null
if (element instanceof HTMLAnchorElement) {
  // element: HTMLAnchorElement
  console.log(element.href);
}

まとめ

  • querySelectorquerySelectorAll に型引数を指定するのは as と同様であり, 型安全とは言えない
    • 誤って null を無視してしまいづらいという点で, as よりは型引数を指定する方がマシではあるが
  • Element よりも具体的な型が必要, かつ上記のように許容される場合に限って型引数を指定すること