【Next.js】Intercepting Routes と Parallel Routes を活用したモーダル実装

## 本記事の内容

この記事では、Next.js の App Router における **Intercepting Routes** と **Parallel Routes** を使用したモーダル実装について解説します。

この機能を組み合わせることで、URLで共有可能なモーダルを実装できます。

## 実装するモーダル

**モーダル非表示（`/parallel`）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/758827b0-dbeb-4955-88ea-65f7a1470f17.png)

**モーダル表示（`/parallel/modal`）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/83b7e85a-410d-415f-88a1-96db0c3d736c.png)

## Intercepting Routes とは

Intercepting Routes とは、**特定のルートへのナビゲーションを「インターセプト（横取り）」して、異なるUIを表示する機能**です。
インターセプトが発生する条件は、**ソフトナビゲーションによる画面遷移**で該当ページが表示されることです。

例） `/parallel/modal` にアクセスした場合
・ソフトナビゲーション → `app/parallel/@modal/(.)modal/page.tsx` が表示
・ハードナビゲーション → `app/parallel/modal/page.tsx` が表示

インターセプトしたいページのフォルダには、先頭に特殊な記号をつけます。
- `(.)` - 同じレベルのセグメントと一致
- `(..)` - 1レベル上のセグメントと一致
- `(..)(..)` - 2レベル上のセグメントと一致
- `(...)` - appディレクトリのルートからのセグメントと一致

### Intercepting Routes でできること
Intercepting Routes を使用することで、従来のモーダルでは実現が難しかった以下の機能を簡単に実現することができます

- モーダルコンテンツをURLで共有可能にする
- ページをリフレッシュしたときも、モーダルを閉じずにコンテキストを保持する
- 戻るナビゲーション時にモーダルを閉じる
- 前方ナビゲーション時にモーダルを再表示する

https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

## Parallel Routes とは

Parallel Routes とは、**複数のルートにある `page.tsx` を、1つのページに条件付きで同時にレンダリングできる機能**です。

### Slots

Parallel Routes を使用するには、「`slots`」をファイル構成に導入します。
- フォルダ名の先頭に`@`をつける
- 表示させたいページまでのセグメントを合わせた構造にする
- `layout.tsx` から使用する

**slotsはURLの構造に影響を与えません。**
例えば、`/@parallel/views`の場合、`@parallel`はスロットなので、URLは`/views`になります。

`layout.tsx` には `children` と共に `slots` が渡されます。

```tsx
export default function Layout({
  parallel,
  children,
}: Readonly<{
  folder: React.ReactNode;
  children: React.ReactNode;
}>) {
  return (
    <>
      <section>{children}</section>
      <section>{parallel}</section>
    </>
  );
}
```

### Active State

`Next.js` は各`slot`について、表示状態を保持します。

`Parallel Routes`では、ページが表示されていた状態で別の階層へソフトナビゲーションが起こった場合、そのURLに対応するページが存在しなくても、遷移前に表示していたページを表示し続けます。

ブラウザリロードなどのハードナビゲーションが発生すると、**ページの状態が把握できなくなるため、対応するページが存在しないURLの場合、そのslotについては`default.tsx`の内容が表示されます。**

何も表示したくない場合は、`default.tsx` で `null` を返すようにします。`default.tsx`がないとエラーが発生します。

```tsx
export default function Default(){
  return null
}
```

https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

## 実装例

### ディレクトリ構造

```
app/
└── parallel/
    ├── layout.tsx        # 親レイアウト
    ├── page.tsx          # メインページ
    ├── modal/
    │   └── page.tsx      # ハードナビゲーション用モーダルページ
    └── @modal/           # Parallel Routes用スロット
        ├── default.tsx   # デフォルト表示（何も表示しない）
        └── (.)modal/     # Intercepting Routes用フォルダ
            └── page.tsx  # モーダル表示用ページ
```

### コード

**app/parallel/layout.tsx**
```tsx
/*
  `/parallel` 配下で共通利用されるレイアウト
  Propsとして、各Slotを受け取る（Slotの表示制御はNext.jsのデフォルトの仕組みで行われる）
  children: メインコンテンツ
  modal: モーダルコンテンツ
*/

export default function ParallelLayout({
  children,
  modal
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Parallel Routes デモ</h1>
      {children}
      {modal}
    </div>
  );
}
```

**app/parallel/page.tsx**
```tsx
/*
  `/parallel` メインページ
*/

import Link from "next/link";

export default function ParallelPage() {
  return (
    <div className="p-4">
      <h2 className="text-xl mb-4">メインページ</h2>
      <Link
        href="/parallel/modal"
        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 inline-block"
      >
        モーダルを開く
      </Link>
    </div>
  );
}
```

**app/parallel/@modal/(.)modal/page.tsx**
```tsx
/*
  ソフトナビゲーションで `/parallel/modal` にアクセスすると表示されるページ
  ハードナビゲーションでは、`app/parallel/modal/page.tsx` が表示される
*/

'use client';

import { useRouter } from 'next/navigation';

export default function ModalPage() {
  const router = useRouter();

  return (
    <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
      <div className="bg-white p-6 rounded-lg max-w-md w-full">
        <h2 className="text-xl font-bold mb-4">モーダル</h2>
        <p className="mb-4">これはParallel Routesを使用したモーダルです。</p>
        <button
          onClick={() => router.back()}
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
        >
          閉じる
        </button>
      </div>
    </div>
  );
}
```

**app/parallel/@modal/default.tsx**
```tsx
/*
  Slotsに対応するページが存在しない場合、 return した内容が表示される
  今回のケースでは return null としているので、何も表示しない。
*/

export default function Default() {
  return null;
}
```

**app/parallel/modal/page.tsx**
```tsx
/*
  ハードナビゲーションで `/parallel/modal` にアクセスすると、表示されるページ
*/
export default function ModalContent() {
  return (
    <div className="p-4">
      <h2 className="text-xl mb-4">モーダルコンテンツページ</h2>
      <p>モーダルを開いた状態でフルリロードすると表示される</p>
    </div>
  );
}
```

## まとめ

`Intercepting Routes` と `Parallel Routes` を活用したモーダル実装の経験がなく挑戦してみましたが、思いの外キャッチアップに時間がかかりました...

ナビゲーション方法によってユーザー体験が異なることに若干の違和感を覚えましたが、モーダルのstate管理が不要になるのは大きなメリットだと感じたので、積極的に活用していきたいと感じました🙋‍♂️

最近 X を始めたので、よければフォローしてください！

https://x.com/penpeen214224


:::note warn

追記: 
上記とは別の環境でモーダルを実装しようとしたところ、Issueと同様のエラーが発生しました。

一部バグが発生するケースがあるかもしれません。

https://github.com/vercel/next.js/issues/72541

:::