【Next.js】Parallel RoutesとIntercept Routesを使ってモダンなモーダルを実装してみた

【Next.js】Parallel RoutesとIntercept Routesを使ってモダンなモーダルを実装してみた

Clock Icon2024.10.28

リテールアプリ共創部のるおんです。
Next.jsのApp Routerには便利な機能がたくさんありますが、今回は Parallel RoutesIntercept Routes を使用してモーダルを実装する方法をご紹介します。

Parallel Routesとは

Parallel Routes(並列ルート)は、同じレイアウト内で複数のページを同時に表示できる機能です。@folderという命名規則でフォルダを作成することで、そのフォルダ内のコンテンツを並列に表示することができます。
例えば、ダッシュボードのような画面で、メインコンテンツと同時にサイドバーやモーダルを表示したい場合に便利です。公式ドキュメントのサンプルが非常にわかりやすいです。
https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

Intercept Routesとは

Next.jsのIntercept Routesは、特定のルートへのナビゲーションを「インターセプト(横取り)」して、異なるUIを表示する機能です。今回のプロジェクトでは、通常のページ遷移をインターセプトしてモーダル表示を実現しています。
https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

プロジェクトの概要

デモ動画
今回は、簡単な猫の写真ギャラリーアプリを作成します。トップページには写真の一覧が表示され、写真をクリックするとモーダルで詳細が表示される仕組みです。

https://youtu.be/TyKJ9SoXisI

注目すべきは以下の点です。

  • モーダルの閉じるボタン以外にも、ブラウザの「戻る」「進む」ボタンでモーダルが閉開できる。
  • モーダルの閉会でURLのpathが変わるが、Stateが保持されている
  • モーダルのURLが共有可能。
  • Progressive Enhancementに対応している

モーダルの閉開によってURLの遷移が行われており、ブラウザの履歴管理との統合されているにもかかわらず、これらはソフトナビゲーションでParallelに描画されるためモーダル前のStateが保持されます。リロードしたり、新しいタブでURLを開いた場合ハードナビゲーションとなるため、インターセプトした元のルートが表示されてURL共有もできるのは嬉しいですよね。

使用する主な技術:
Next.js 14
shadcn/ui(Dialogコンポーネント)
TypeScript
Tailwind CSS

実装

ディレクトリ構成

まず最初に以下が今回のディレクトリ構成です。

/
├─ app
│  ├─ _components
│  │  ├─ CatPhotoList.tsx
│  │  ├─ CatPhotoListItem.tsx
│  │  ├─ Modal.tsx
│  │  └─ Header.tsx
│  ├─ _data
│  │  └─ catPhotos.ts
│  ├─ @modal
│  │  └─ (.)photo
│  │     └─ [photoId]
│  │        └─ page.tsx
│  │     └─ default.tsx
│  └─ photo
│     └─ [photoId]
│        └─ page.tsx
├─ layout.tsx
└─ page.tsx

レイアウト作成

いまここ
 /
 ├─ app
 │  ├─ _components
 │  │  ├─ CatPhotoList.tsx
 │  │  ├─ CatPhotoListItem.tsx
 │  │  ├─ Modal.tsx 
+ │  │  └─ Header.tsx
 │  ├─ _data
 │  │  └─ catPhotos.ts
 │  ├─ @modal
 │  │  └─ (.)photo
 │  │     └─ [photoId]
 │  │        └─ page.tsx
 │  │     └─ default.tsx
 │  └─ photo
 │     └─ [photoId]
 │        └─ page.tsx
+ ├─ layout.tsx
 └─ page.tsx

ルートのlayout.tsxに以下のように実装したHeaderを配置しました。

Header.tsx
Header.tsx
import Link from "next/link";

export default function Header() {
  return (
    <header className="border-b-2 px-8 py-4 mb-4 container mx-auto">
      <Link href="/">
        <h1 className="text-3xl text-center">猫の写真</h1>
      </Link>
    </header>
  );
}
layout.tsx
export default function RootLayout({
  children,
  modal,
}: Readonly<{
  children: React.ReactNode;
  modal: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
+       <Header />
        {children}
      </body>
    </html>
  );
}

ここが完成します。
スクリーンショット 2024-10-28 16.44.27

写真一覧の作成

いまここ
 /
  ├─ app
  │  ├─ _components
+ │  │  ├─ CatPhotoList.tsx
+ │  │  ├─ CatPhotoListItem.tsx
  │  │  ├─ Modal.tsx
  │  │  └─ Header.tsx
+ │  ├─ _data
+ │  │  └─ catPhotos.ts
  │  ├─ @modal
  │  │  └─ (.)photo
  │  │     └─ [photoId]
  │  │        └─ page.tsx
  │  │     └─ default.tsx
  │  └─ photo
  │     └─ [photoId]
  │        └─ page.tsx
  ├─ layout.tsx
+ └─ page.tsx

まず、今回表示してあげたい猫の写真データをあらかじめ用意しておきます。本来ならこれらのデータをDBやサーバーから取得できるようにしておきますが、今回はモックしています。

猫のデータを用意
app/_data/catPhotos.ts
export const catPhotos = [
  {
    id: 1,
    title: "お座りニャンコ",
    path: "/images/cat_image1.jpg",
  },
  {
    id: 2,
    title: "こちらを見上げるニャンコ",
    path: "/images/cat_image2.jpg",
  },
  {
    id: 3,
    title: "夢の中のニャンコ",
    path: "/images/cat_image3.jpg",
  },
];

次にchildrenの中身となるルートのpage.tsxを作っていきます。
猫の写真一覧を表示するCatPhotoList.tsxと、猫のカード単体を表示するCatPhotoListItem.tsxを作ってそれを描画しています。

app/page.tsx
import CatPhotoList from "./_components/CatPhotoList";
import { catPhotos } from "./_data/catPhotos";

export default async function RootPage() {
  return (
    <main className="container mx-auto px-4">
+     <CatPhotoList photoDataList={catPhotos} />
    </main>
  );
}
CatPhotoList.tsx

猫の写真をループさせて表示しています。また、Stateの保持をわかりやすくするためuseStateで状態管理をするようにしています。

app/_components/CatPhotoList.tsx
"use client";

import { useState } from "react";
import { PhotoData } from "../photo/[photoId]/page";
import PhotoDisplay from "./CatPhotoListItem";

export default function CatPhotoList({
  photoDataList,
}: {
  photoDataList: PhotoData[];
}) {
  const [count, setCount] = useState(0);
  return (
    <>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 pb-8">
        {photoDataList.map((photoData) => (
          <CatPhotoListItem key={photoData.id} photoData={photoData} />
        ))}
      </div>
      <div className="flex flex-col items-center gap-4 py-8 border-t">
        <p className="text-xl font-semibold">カウント: {count}</p>
        <button
          onClick={() => setCount(count + 1)}
          className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 transition-opacity"
        >
          カウントを増やす
        </button>
      </div>
    </>
  );
}
CatPhotoListItem.tsx

Linkコンポーネントを使用して、猫の写真を押したら/photo/${photoData.id}にソフトナビゲーションするようにしています。

src/app/_components/CatPhotoListItem.tsx
"use client";

import Image from "next/image";
import Link from "next/link";
import { PhotoData } from "../photo/[photoId]/page";

type Props = {
  photoData: PhotoData;
};

export default function CatPhotoListItem({ photoData }: Props) {
  return (
    <>
      <div className="flex flex-col gap-4">
        <h1 className="text-3xl text-center">{photoData.title}</h1>
        <Link href={`/photo/${photoData.id}`}>
          <div className="border-2 rounded-xl overflow-hidden aspect-square relative">
            <Image
              src={photoData.path}
              alt={photoData.title}
              sizes="256px"
              fill
              style={{
                objectFit: "cover",
              }}
            />
          </div>
        </Link>
      </div>
    </>
  );
 }

猫ちゃんたちの画像一覧が見れる見た目ができましたね。
スクリーンショット 2024-10-28 18.19.46

ただ、まだこのままだと写真をクリックしても/photo/[photoId]に遷移するだけで何も表示されません。

詳細ページ作成(/photo/[photoId])

いまここ
 /
  ├─ app
  │  ├─ _components
  │  │  ├─ CatPhotoList.tsx
  │  │  ├─ CatPhotoListItem.tsx
  │  │  ├─ Modal.tsx 
  │  │  └─ Header.tsx
  │  ├─ _data
  │  │  └─ catPhotos.ts
  │  ├─ @modal
  │  │  └─ (.)photo
  │  │     └─ [photoId]
  │  │        └─ page.tsx
  │  │     └─ default.tsx
+ │  └─ photo
+ │     └─ [photoId]
+ │        └─ page.tsx
  ├─ layout.tsx
  └─ page.tsx

次に、各写真の詳細ページを作ります。Dynamic Routesを使用してphotoIdを受け取ってその写真を表示させます。先ほど作ったCatPhotoListItemを再利用してあげます。

app/photo/[photoId]/page.tsx
import { catPhotos } from "@/app/_data/catPhotos";
import CatPhotoListItem from "../../_components/CatPhotoListItem";

export type PhotoData = {
  id: number;
  title: string;
  path: string;
};

type Props = {
  params: {
    photoId: string;
  };
};

export default async function PhotoPage({ params: { photoId } }: Props) {
  const photoData = catPhotos.find((photo) => photo.id === parseInt(photoId));

  if (!photoData) {
    return <h1>写真が見つかりません</h1>;
  }

  return (
    <div className="mt-2 grid place-content-center">
      <CatPhotoListItem photoData={photoData} />
    </div>
  );
}

これで写真一覧から写真を選択すると詳細画面が見ることができます。
スクリーンショット 2024-10-28 18.43.44

ここまでは一般的なNext.jsを利用した画面遷移です。
それではここからはphoto/[photoId]をインターセプトしてモーダルとして表示できるようにしたいと思います。

モーダルの作成

いまここ
 /
  ├─ app
  │  ├─ _components
  │  │  ├─ CatPhotoList.tsx
  │  │  ├─ CatPhotoListItem.tsx
+ │  │  ├─ Modal.tsx
  │  │  └─ Header.tsx
  │  ├─ _data
  │  │  └─ catPhotos.ts
+ │  ├─ @modal
+ │  │  └─ (.)photo
+ │  │     └─ [photoId]
+ │  │        └─ page.tsx
+ │  │     └─ default.tsx
  │  └─ photo
  │     └─ [photoId]
  │        └─ page.tsx
  ├─ layout.tsx
  └─ page.tsx

モーダルとして表示できるようにModalコンポーネントを作成します。Shadcn/uiからDialogコンポーネントをインポートして利用しています。

Modal.tsx
zsh
npx shadcn@latest add dialog
"use client"

import {
    Dialog,
    DialogOverlay,
    DialogContent,
} from "./ui/dialog"
import { useRouter } from "next/navigation"

export function Modal({
    children,
}: {
    children: React.ReactNode
}) {
    const router = useRouter()

    const handleOpenChange = () => {
        router.back()
    }

    return (
        <Dialog defaultOpen={true} open={true} onOpenChange={handleOpenChange}>
            <DialogOverlay>
                <DialogContent className="overflow-y-hidden">
                    {children}
                </DialogContent>
            </DialogOverlay>
        </Dialog>
    )
}

このModalコンポーネントに加え、Parallel RoutesとIntercepting Routesを組み合わせることでモーダル機能を実現できます。
以下のディレクトリを見てなんだこれはと思った方も多いでしょう。これらはそれぞれParallel RoutesとIntercepting Routesのディレクトリの規則です。

 │  ├─ @modal
 │  │  └─ (.)photo
 │  │     └─ [photoId]
 │  │        └─ page.tsx
 │  │     └─ default.tsx

@modal - Parallel Routesのためのスロット
(.)photo - Intercepting Routesのための特別な命名規則

以下解説します。

Parallel Routes

@modalディレクトリを作成することでParallel Routesとして明示的に登録できます。

+  ├─ @modal
   │  └─ (.)photo
   │     └─ [photoId]
   │        └─ page.tsx
+  │     └─ default.tsx

これはセグメントとしてURLのパスには影響をしません。その際、スロットとしてレイアウトファイルに渡してあげる必要があります。詳しくはこちら

app/layout.tsx
export default function RootLayout({
  children,
+ modal,
}: Readonly<{
  children: React.ReactNode;
  modal: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Header />
+       {modal}
        {children}
      </body>
    </html>
  );
}

Parallel Routesを使用することで、メインコンテンツとモーダルを同時に表示できます。つまり、@modalchildren(今回の場合はルートのpage.tsx)が同時に描画されます。これにより、Partial renderingを実現できるため、写真一覧(app/page.tsx)と同時に@modal配下のコンポーネントが描画されるので写真一覧画面のStateが保持されます。

【補足】default.tsxについて
Parallel Routesを使用する際、@modalスロットに対応するコンテンツが存在しない場合(例:ホームページ表示時)に表示される「デフォルト」のUIを定義するために、default.tsxが必要です。

default.tsx
export default function Default() {
  return null;
}

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

Intercepting Routes

app/@modal/(.)photo/[photoId]/page.tsxを作成します。

   ├─ @modal
+  │  └─ (.)photo
+  │     └─ [photoId]
+  │        └─ page.tsx
   │     └─ default.tsx

(.)はマッチさせたいルート(今回の場合はapp/photo/[photoId])のセグメントのレベルに応じて.を対応させます。詳しくはこちら
これにより、ソフトナビゲーションが実行された際 にはこのIntercepting Routes(app/@modal/(.)photo/[photoId]/page.tsx)が本来のルート(app/photo/[photoId]/page.tsx)を横取りして描画されます。一方で、リロードやJSが無効な環境においては ハードナビゲーション が実行されて本来のルート(app/photo/[photoId]/page.tsx)が描画されます。

このIntercepting RoutesではModalコンポーネントを使用してモーダルを実現しています。

app/@modal/(.)photo/[photoId]/page.tsx
import CatPhotoListItem from "@/app/_components/CatPhotoListItem";
import { catPhotos } from "@/app/_data/catPhotos";
import { Modal } from "@/components/Modal";

type Props = {
  params: {
    photoId: string;
  };
};

export default async function Photo({ params: { photoId } }: Props) {
  const photoData = catPhotos.find((photo) => photo.id === parseInt(photoId));

  if (!photoData?.id) {
    return <h1 className="text-center">No Photo Found for that ID.</h1>;
  }

  return (
    <Modal>
      <CatPhotoListItem photoData={photoData} />
    </Modal>
  );

この状態で、写真一覧から選択すると...
スクリーンショット 2024-10-28 19.10.55
このようにURLのパスがphoto/1にも関わらず、モーダルの方が表示されていますね!
これはNext.jsが用意するLinkコンポーネントがソフトナビゲーションを実行してくれるからです。そしてその際にはIntercept Routesによってphoto/[photoId]の画面を横取りしてモーダルを表示しています。また、Parallelで写真一覧(page.tsx)と同時にこの画面が描画されているので写真一覧画面のStateも保持されているということです。

一方で、ブラウザをリロードしたり、localhost:3000/photo/1を共有して他のタブで見ると、それはLinkコンポーネントを介さずにシンプルにpathにアクセスするため、本来のルート(app/photo/[photoId])の方が表示されます。
スクリーンショット 2024-10-28 18.43.44

また、JavaScriptを無効にした場合もLinkコンポーネントが通常の<a>タグとして機能するためIntercepting Routesは無視されて完全なページ遷移として動作します。
以下は、JavaScriptを有効にしたときと、無効にした時の挙動の違いです。

https://youtu.be/4fJ63xNs1VM

JavaScriptが無効なときは本来のルート(app/photo/[photoId])が表示されており、Progressive Enhancementとしての機能も備えていることがわかります。

おわりに

今回はNext.jsの機能であるParallel RoutesとIntercept Routesを使ってモーダルを実装してみました。Stateを保持したままURL Pathの遷移でモーダルを実装できたり、URL共有することができるモーダルを作れるのはとてもユーザー体験がよくていいと思いました!
正直Next.jsのApp Router以後のディレクトリ構成はかなり癖があり、覚えることが多いですが、Partial RenderingやErroハンドリング、今回のようなソフトナビゲーションによるモーダル表示などの機能を実現するには普通のルーティング設定では実現するのが難しいのでそこに面白さを見出して学習を続けていきたいと思います。

どなたかの参考になれば幸いです。

参考

https://nextjs.org/docs

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.