👨‍🎨

【App Router】Parallel RoutesとIntercepting Routesで作るモダンなモーダル機能

2024/08/22に公開

はじめに

こんにちは!
株式会社BLUEISH エンジニアの佐々木(@osasasasa22)です。

昨今のウェブアプリ開発においては、UXがますます重要視されています。
たとえばInstagramのようなモダンで直感的なUXを持つアプリは、ユーザーにとって使い勝手が良く、訪問者の心を掴む力がありますよね🤔

直感的なUXの例(Instagram)

本記事では、App Routerの機能である「Parallel Routes」と「Intercepting Routes」を駆使して、直感的なUXをシンプルな実装で生み出す方法を探ってみたいと思います!

https://zenn.dev/blueish/articles/4b2ae3781ade57
https://zenn.dev/blueish/articles/61526c0983362e

Parallel Routes

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

Parallel Routesとは

公式には、「Parallel Routesは、1つまたは複数のページを同じレイアウト内で同時に、または条件付きでレンダリングできる機能です。」と記載があります。

Parallel Routes allows you to simultaneously or conditionally render one or more pages within the same layout.

簡単にいうと、1つの画面の中で、複数の異なるページを同時に表示したり、状況に応じて切り替えたりすることができます。
たくさんの情報を一度に見せたい場合や、ユーザーの操作に応じて画面の一部だけを変更したい場合に便利な機能です。

例えば、ダッシュボードページを実装する際にParallel Routesを使用することで、チーム情報、分析データ、タスクリストなど、複数の独立したコンテンツを同時に表示することができます。

Pages Routerとの比較

こうした実装をするには、従来のPages Routerだと少し手間がいりました。
Pages Routerの主な制限点と、Parallel Routesがどのようにそれを解決するかについてまとめると、こんな感じです。

  1. 単一ページ構造
    【Pages Router】 1つのURLに対して1つのページコンポーネントしか関連付けられませんでした。
    【Parallel Routes】 1つのURLで複数の独立したコンテンツ領域を同時にレンダリングできます。

  2. 動的コンテンツの管理
    【Pages Router】 動的なコンテンツ表示には、状態管理ライブラリやカスタムロジックが必要でした。
    【Parallel Routes】  ルーティングシステム自体が動的コンテンツの管理をサポートしています。

  3. 条件付きレンダリング
    【Pages Router】 条件付きレンダリングは、コンポーネントレベルで実装する必要がありました。
    【Parallel Routes】 ルーティング層で条件付きレンダリングを簡単に実装できます。

  4. 独立したローディングとエラー状態
    【Pages Router】 ページ全体で1つのローディングとエラー状態を共有していました。
    【Parallel Routes】  各ルートが独立したローディングとエラー状態を持つことができます。

Parallel Routesの実装方法

続いて、Parallel Routesの実装方法についてみていきます。

Parallel Routesは「スロット」という概念を使用して実装されます。
スロットは @[ディレクトリ名] という規則で定義され、それぞれが独立したコンテンツ領域を表します。

例えば、

app/
  feed/
    page.tsx      # フィードのメインコンテンツを表示するページ
    layout.tsx    # フィード全体のレイアウトを定義するファイル
    @photo/       # 写真表示用のParallel Routeスロット
      [id]/       # 動的ルートセグメント(個別の写真IDに対応)
        page.tsx  # 個別の写真を表示するページ
    @sidebar/     # サイドバー用のParallel Routeスロット
      page.tsx    # サイドバーのコンテンツを表示するページ

この構造では、@photo@sidebar がそれぞれ独立したスロットとして定義されています。
スロットは layout.tsx ディレクトリ内でpropsとして利用できます。

/app/layout.tsx
type FeedLayoutProps = {
  children: React.ReactNode
  photo: React.ReactNode
  sidebar: React.ReactNode
}

export default function FeedLayout({ children, photo, sidebar }: FeedLayoutProps) {
  return (
    <div className="feed-layout">
      <main className="feed-main">{children}</main>
      <aside className="feed-sidebar">{sidebar}</aside>
      <div className="photo-modal">{photo}</div>
    </div>
  );
}

ちなみに、スロットはURLの構造に影響しません!
スロットがルートセグメント(URLの一部)として扱われないためです。
例えば、@analytics/viewsというフォルダ構造の場合、@analyticsはスロットを表すので、このページへのURLは単に/viewsとなります。

Intercepting Routes

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

Intercepting Routesとは

"Intercept" という英語は、「横取りする」「さえぎる」「途中で捕らえる」という意味があります。
例えば、スポーツでパスを途中で奪うことや、通信を途中で傍受することなどを "intercept" と表現します。

Next.jsのドキュメントでは、Intercepting Routesは文字通り「ルート(経路)を途中で捕らえる」機能を指しているようにみえます。
つまり、Intercepting Routesとは、特定のURLパターンへのアクセスを「横取り」して、元々予定されていた表示内容の代わりに別のコンテンツを表示する機能です。

Intercepting Routesを使用すると、現在のページのコンテキストを維持したまま、異なるルートの内容を現在のレイアウト内に表示できる = ページ全体を遷移させることなく、新しいコンテンツを表示することができます。

Intercepting Routesの実装方法

さて、Intercepting Routesの基本的な概念を理解したところで、次はその詳細設定と実装方法について見ていきます!

Intercepting Routesを設定するには、特別なフォルダ名の規則を使います。
これは、ファイルシステムの相対パス(../など)に似ていますが、少し違いがあります。

使える記号とその意味

(.)                 # 同じレベルのセグメントをさえぎる
(..)               # 1つ上のレベルのセグメントをさえぎる
(..)(..)       # 2つ上のレベルのセグメントをさえぎる
(...))           # 最上位(appディレクトリ直下)のセグメントをさえぎる

例えば、feedというページの中にあるphotoセグメントをさえぎりたい場合、こんなフォルダを作ります。

app/
├── feed/
│   ├── page.js
│   └── (.)photo/
│       └── [id]/
│           └── page.js
└── photo/
    └── [id]/
        └── page.js

Parallel RoutesとIntercepting Routesを使用した高度なモーダル機能

Parallel RoutesとIntercepting Routesの機能を理解したところで、いよいよ本題です。
前節で説明したParallel RoutesとIntercepting Routesの概念を組み合わせることで、InstagramのようなUXを実現できます。

どういうことかというと、以下のような難易度の高そうな機能を簡単に実装できるようになります🙌🏻

  1. ユーザーがプロフィールページで写真のサムネイルをクリックすると、ページ全体を再読み込みすることなく、その場でモーダルウィンドウ内に写真が表示される
  2. モーダルが開かれると同時に、URLも更新される(例えば、/profile/user123から/profile/user123/photo/456に変わる)
  3. この更新されたURLを直接共有したり、ブラウザで開いたりすると、モーダルではなく通常のレイアウトで同じ写真が表示される
  4. ユーザーがモーダルを閉じると、元のプロフィールページのURLに戻る

    こうしたスムーズな動きと、個別のページに直接飛べる便利さを両立することができるんです!
    また、またこの組み合わせは、モーダルを通常のページと切り離して管理することができ、SEO対策やコードの整理にも役立ちます!

機能を確認したところで、具体的な実装方法を見ていきましょう。
今回はVercelが公開しているサンプルアプリ、Nextgramのコードで解説します。
https://github.com/vercel/nextgram

基本的な構造

まず、Nextgramのフォルダ構造を確認します。

app/
├── layout.tsx          # アプリ全体のレイアウト
├── page.tsx            # トップページ(写真一覧)
├── photos.ts           # 写真データ
├── components/
│   ├── frame.tsx       # 写真フレームのコンポーネント
│   └── modal.tsx       # モーダルのコンポーネント
├── photo/
│   └── [id]/
│       └── page.tsx    # 個別の写真ページ
└── @modal/             # モーダル用のParallel Routesスロット
    └── (..)photo/      # Intercepting Routes設定
        └── [id]/
            └── page.tsx # モーダル内の写真表示ページ

この構造では、@modalフォルダがParallel Routesのスロットとして機能し、(.)photoがIntercepting Routesの設定を表しています。

実装の解説

それでは、実際のコードを見ていきましょう。

実装の解説 | アプリの基本構造の定義

まず、layout.tsxでアプリ全体のレイアウトを定義しています。

app/layout.tsx
import './global.css';

export const metadata = {
  title: 'NextGram',
  description: 'A sample Next.js app showing dynamic routing with modals as a route.',
};

export default function RootLayout(props: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {props.children} {/* メインコンテンツがここに表示されます */}
        {props.modal}    {/* モーダルがここに表示されます */}
        <div id="modal-root" /> {/* モーダルの表示位置を指定するための要素 */}
      </body>
    </html>
  );
}

childrenmodalの2つのpropsを受け取っていますね。
childrenには、メインとなる通常のページコンテンツが入り、modalにはParallel Routesで定義したモーダルコンテンツが入ります。
この構造によって、メインコンテンツとモーダルを同時に表示しつつ、管理は別にする、ということが実現できます。

実装の解説 | トップページ

次に、アプリのトップページのコードです。

app/page.tsx
import Link from 'next/link';

export default function Page() {
  // 1から6までの数字の配列を作成(これが写真のIDになりる)
  let photos = Array.from({ length: 6 }, (_, i) => i + 1);

  return (
    <section className="cards-container">
      {photos.map((id) => (
        // 各写真(ここでは数字)へのリンク
        <Link className="card" key={id} href={`/photos/${id}`} passHref>
          {id}
        </Link>
      ))}
    </section>
  );
}

このページでは、1から6までの数字を表示しています。
実際のアプリでは、ここに写真のサムネイルなどが表示する想定ですが、サンプルアプリなので簡略化しているものと思えます。
各数字(実際のアプリでは写真)をクリックすると、/photos/[id]に移動するようになっています。

実装の解説 | モーダル

つづいて、モーダルの表示部分のコードをみてみます。

app/@modal/(.)photos/[id]/page.tsx
import { Modal } from './modal';

export default function PhotoModal({
  params: { id: photoId },
}: {
  params: { id: string };
}) {
  return <Modal>{photoId}</Modal>;
}

@modalというフォルダ名がParallel Routesを、(.)がIntercepting Routesを表しています。
つまり、このコンポーネントは/photos/[id]へのアクセスを「横取り」して、モーダルとして表示しています!

実装の解説 | 通常の写真ページ

最後に、URLを直接叩いた場合に表示される通常の写真ページ部分のコードです。

app/photos/[id]/page.tsx
export const dynamicParams = false;

export function generateStaticParams() {
  let slugs = ['1', '2', '3', '4', '5', '6'];
  return slugs.map((slug) => ({ id: slug }));
}

export default function PhotoPage({
  params: { id },
}: {
  params: { id: string };
}) {
  return <div className="card">{id}</div>;
}

このページは、直接/photos/[id]にアクセスした場合に表示されます。
また、generateStaticParams関数によって、ビルド時に6つの写真ページが事前に生成されます。

実装の解説 | 動作の仕組み

まず、ユーザーがトップページで数字(実際のアプリでは写真)をクリックすると、URLは /photo/2 に変わり、Intercepting Routesの仕組みにより、app/@modal/(..)photo/[id]/page.tsx の内容がモーダルとして表示されます。

さらに、Parallel Routesのおかげで、このモーダルはメインコンテンツと並行して表示されます。

また、ユーザーが直接 /photo/2 にアクセスした場合には、通常の app/photo/[id]/page.tsx が表示されます。

検索エンジンには通常ページとしてこちらのページが認識されるので、SEO対策もばっちりですね👏🏻

おわりに

App Routerの機能、Parallel RoutesとIntercepting Routesを使ったモーダル実装について解説しました。
Parallel RoutesとIntercepting Routesは一見複雑に見えるかもしれませんが、サンプルアプリを元に実際に手を動かしてみると、非常に便利な機能であることがわかると思います。

ぜひ今回学んだ概念をベースに、ご自身のプロダクトにおいて最適なUXを設計してみてください🙏🏻

最後までお読みいただきありがとうございました。

Discussion