「Parallel & Intercepting Routes」でURL同期モーダルを作る
Next.js(App Router)でかなり便利なのに、仕様が分かるまで苦戦した、Parallel RoutesとIntercepting Routes の組み合わせについて書きます。
Xなどで「投稿一覧で写真をタップしたらURLは変わるのにモーダルで開き」→「リロードしたら詳細ページ単体で開く」みたいな実装ありますよね。あのようなのを作ろうとして丸一日かかったのでやったことを残してみます。
作りたかったもの
こんな感じのフォトギャラリーを想定してました。
/galleryにアクセス → 画像一覧が表示される/gallery/photo/1をクリック → URLが変わって、一覧の上にモーダルがふわっと浮かぶ- そこからリロード、または直リンク → モーダルじゃなく、詳細ページ単体が全画面で開く
Next.js の App Router ならステート(isOpenとか)なしで、URLと完全に同期した形でいけるはずでした。
ディレクトリ構造
公式ドキュメント読みながら、こんな構成にしました。
src/app/
└── gallery/
├── @modal/ # Parallel Route(モーダル用)
│ ├── (.)photo/ # Intercepting Route(同階層のphotoをインターセプト)
│ │ └── [id]/
│ │ └── page.tsx
│ └── default.tsx # モーダル閉じてる時の空コンポーネント
├── photo/ # 直リンク・リロード用の通常ルート
│ └── [id]/
│ └── page.tsx
├── layout.tsx # @modalとchildrenを並べる
└── page.tsx # 画像一覧gallery/layout.tsx
Parallel Routesを使って、propsから children(一覧)と modal(モーダル)を受け取って並べます。
import React from 'react';
export default function GalleryLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div className="relative">
{children}
{modal}
</div>
);
}gallery/@modal/default.tsx
ここは重要で、モーダルが表示されてない時は何も描画しないnullを返すファイルが必要です。ないと Next.jsが「ルート未定義」で404を吐いたりします。
export default function Default() {
return null;
}つまづいたところ
実装してみたら、色々とつまづきました。
インターセプトが効かない
一覧から<Link href="/gallery/photo/1">をクリックしても、モーダルが開かず普通に全画面の詳細ページに遷移しちゃいました。
対策:
インターセプト記法の(.)photoの「階層指定」を勘違いしてました。ドキュメントで「(..)は 1 つ上の階層」って書いてあるから、@modalから見て上に行くのかな?みたいに思ってました。
でも実は、スロット(@modal みたいなやつ)は URL の階層にカウントされません。 つまり、@modalを無視して、ベースの gallery/から見た相対位置で計算する必要があります。gallery/ から見て photo/ は同じ階層だから、正解は (.)photo でした。
それでも動かなかったのは、開発サーバーのキャッシュのせい。フォルダ構造を変えたときは .nextディレクトリを削除して、サーバー再起動すれば OK でした。構造いじるたびにこれやらないと厳しい。
「戻る」でモーダル閉じても画面が残ってる
モーダルの閉じるボタンにrouter.back()を仕込んで、ブラウザの戻るボタンでもモーダル閉じられるようにしました。
本来なら router.back() で URL が /gallery に戻ると、Next.js が自動で @modal スロットに default.tsx(null を返すやつ)を適用して、ステートなしで自然にモーダルが消える仕様なはず。
…だと思ったんですが、URL は戻るのに、画面のモーダルが消えずに残り続けるという奇妙な現象が。App Router 特有の挙動みたいです。
対策:
ステートレスを諦めて、コンポーネント側にisOpenステート持たせることにしました。確実に対処するために。あとNext.js 15からは paramsが非同期(Promise)なので、use clientではReact.use()で展開します。
// gallery/@modal/(.)photo/[id]/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useState, use } from 'react';
export default function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const { id } = use(params);
const [isOpen, setIsOpen] = useState(true);
const handleClose = () => {
setIsOpen(false);
router.back();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-lg w-full">
<h2>写真 ID: {id}</h2>
<button onClick={handleClose} className="mt-4 px-4 py-2 bg-blue-500 text-white rounded">
閉じる
</button>
</div>
</div>
);
}ちなみに router.back()より、router.push('/gallery', { scroll: false })で明示的に一覧に飛ばしちゃう方が、履歴状態に左右されず安定します。
アニメーションが効かない
モーダルにふわっとフェードインするアニメーション付けたくて、Tailwind CSS使おうとしました。でも直リンクのときは全画面表示にしたいから、モーダル用と全画面用で同じ詳細コンポーネント使い回してたんです。
そのせいで、モーダル専用のアニメーション(背景の黒いレイヤーとか)どこに仕込むのか混乱しました。
対策:
コンポーネント共通化を中身のコンテンツだけに留めて、外枠(モーダル背景やアニメーション)は gallery/@modal/(.)photo/[id]/page.tsxで完全に切り分けました。
- モーダル側: 背景フェードイン + 中央コンテンツのポップアップアニメーション
- 全画面側: 普通にページレイアウトに配置
役割をきっちり分けたら、コードが整理されてアニメーションも意図通り動きました。
思ったこと
Parallel & Intercepting Routes は一度組んじゃえば、UXがかなり良くなります。でも今回挙げたような引っかかりやすい点はありました。
普通にuseStateで管理した方が圧倒的に楽では…って途中で挫折しそうでしたけど、実際にリロードしてもちゃんとモーダルが開くの見てみたら、導入してよかったって思いました。
