📷

公式実装例NextGramを参考にParallelRoutesとInterceptingRoutesでModalを実装しよう

2024/05/02に公開

NextGram

Next.js の公式が出している実装例の1つにNextGramというものがあります。

NextGramでは、条件に応じてもともと表示していたページをバックグラウンドに残しつつModalを開いたり、Modal内のコンテンツだけをページに表示するよう切り替えたりできるようになっています。

https://nextgram.vercel.app/

https://github.com/vercel/nextgram

本記事ではこちらの実装例と関連するApp Routerの機能であるParallel RoutesやIntercepting Routesについてみていきたいと思います。

App RouterのModal

NextGram は、App RouterのParallel RoutesとIntercepting Routesを組み合わせてModalを表示する公式のサンプルアプリです。
このアプリで実装されているModalは、以下に挙げるような従来のModal実装方法の懸念点が解消されたものとなっています。

  • Modalに表示されているコンテンツがURLで共有できない
  • ページがブラウザのリロードなどで更新された際にModalの内容が消えてしまう
  • ブラウザの戻る/進むでModalが消える/現れるのではなくページごと遷移してしまう

これらの懸念点を解消した実装は、Modal内にコンテンツを表示するのか、Modalを使用せずにそのまま表示するのかを画面遷移の方法に応じて切り替えられることによって実現されています。

画面遷移の方法によって、表示内容を切り替えるために用いられているルーティング方法が、Parallel RoutesIntercepting Routesです。
Modalの実装をみていく前に、画面遷移の種類とこれらのルーティング方法について確認したいと思います。

画面遷移の種類

Next.jsの画面遷移の種類には、ハードナビゲーションとソフトナビゲーションがあります。

ハードナビゲーション

ハードナビゲーションは、ブラウザによる再読み込みを伴うページ遷移のことで、ブラウザの更新ボタンによる再読み込みやwindow.location.hrefによる画面遷移などが該当します。
React のstate は保持されません

ソフトナビゲーション

ソフトナビゲーションは、SPAならではの画面遷移で、Next.jsの<Link>コンポーネントによる画面遷移やuseRouterpush()などによる画面遷移が該当します。
ブラウザによるページ全体の再読み込みを伴わず、必要な箇所のみ再レンダリングされるため、高速で快適なユーザー体験をもたらします。
https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#5-soft-navigation

Parallel Routes

Parallel Routesを使用すると、複数のroute にあるpage.tsxを同じlayout.tsxを使用して、1つのページに同時にレンダリングできます。
もちろん描画するpage.tsxは1つでもよく、条件によってレンダリングするかどうかをそれぞれ決めることもできます。

Parallel Routesを使用するためには、ファイル構成に「slots」と呼ばれるものを導入してParallel Routesの使用を明示する必要があります。

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

slots

Parallel Routes を使用することはslots によって宣言できます。
slots は@folderの形式で表され、slots に含まれる"page"は親のlayout.tsxにpropsのように渡されます。

親のlayout.tsxには元々childrenが渡されますが、これとは並行してfolderが渡されるようになります。children自体も暗黙的にslots (@children)であるとみなせます。
ここに条件分岐を追加することで、描画する要素を切り替えることも可能です。

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

slotsはURLの構造に影響を与えません。

Parallel Routes では、その名の通り複数のページが並行してレンダリングされます。
slotsはURLに影響を与えないため、同じURL に対して複数のページが該当することになります。このことによって、並行した複数ページのレンダリングが実現できるわけですね。

active state

Next.js は各slot について、active state と呼ばれる「表示・非表示状態」を追跡します。
active state とは、slot に属するpage が描画されていたかどうかの状態を表すものです。

Parallel Routes では、あるpage が表示されていた状態で別の階層へソフトナビゲーションが起こった場合には、そのURLに対応するpage が存在しない場合でも、遷移前に表示していたpage を表示し続けます
つまり、各slot についてどのpage がアクティブだったか(表示されていたか)を追跡しており、これが各slotのactive state と呼ばれるものになります。

ただし、ブラウザリロードなどのハードナビゲーションが起こると、active state が把握できなくなるため、対応するpage が存在しないURL の場合には、そのslot については代わりにdefault.tsxの内容が表示されます。
何も表示したくない場合には、default.tsx でnull を返すようにしておく必要があります(default.tsxを用意しないとエラーページが表示されてしまいます)。

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

ナビゲーション方法によるコンテンツの表示切り替え

Parallel Routes では、ナビゲーションの種類によって表示内容が切り替えられます。
例えば、以下のようなslots を含むディレクトリ構造があるとします。

app
└── parallel-routes
    ├── @teams
    │   ├── settings
    │   │    └── page.tsx
    │   └── default.tsx
    │ 
    ├── @analytics
    │   ├── default.tsx
    │   └── page.tsx
    ├── default.tsx
    ├── layout.tsx
    └── page.tsx

teams, analytics, children slot のpage は以下のようにlayout.tsxで受け取って表示できます。

layout.tsx
import "../globals.css";
import Link from "next/link";
import HardNavigationButton from "./HardNavigationButton";

export default function Layout({
  teams,
  analytics,
  children,
}: Readonly<{
  teams: React.ReactNode;
  analytics: React.ReactNode;
  children: React.ReactNode;
}>) {
  return (
    <>
        <Link href="/parallel-routes">
          <button className="m-4 bg-blue-400">soft navigete to root</button>
        </Link>
        <br />
        <Link href="/parallel-routes/settings">
          <button className="m-4 bg-blue-400">soft navigate to settings</button>
        </Link>
        <br />
        <HardNavigationButton />

        <section className="m-4">{children}</section>
        <section className="m-4">{teams}</section>
        <section className="m-4">{analytics}</section>
    </>
  );
}



Parallel Routes の動作を確認してみます。
各slot でpage の内容が表示されているのか、default.tsxの内容が表示されているかが区別できるよう、先述の例と同じディレクトリ構造を持つコードで確認してみます。
https://github.com/axoloto210/zenn-article/tree/main/nextgram-modal

下のgif 画像では/parallel-routesから/parallel-routes/settingsへソフトナビゲーションで遷移した後で再び/parallel-routesへ遷移しています。





/parallel-routesへアクセスした時、layout.tsxにはchildren, teams, analyticsが渡されるわけですが、teams slot には/parallel-routesに対応するページがないため、@teams/default.tsxの内容が表示されます。
childrenanalytics のpage 内容については、どちらも表示されます(紫色の箇所)。

ここで、/parallel-routes/settings へソフトナビゲーションが起こると、teams slot については@teams/settings/page.tsx の内容が表示されますが、対応するページがないはずのanalytics slot, children slot についても、default.tsxの内容が表示されるのではなく、/parallel-routesで表示されていた内容がそのまま表示され続けます

@analytics, @childrenについてはソフトナビゲーションによる遷移前にコンテンツが表示されていたということをactive stateとしてNext.js が追跡・把握できているために、遷移後もコンテンツをそのまま表示するという判断ができているわけですね。
再び/parallel-routesへソフトナビゲーションが起こると、初めの表示とは異なり@teams/default.tsxではなく@teams/settings/page.tsxの内容が引き続き表示されています。




今度はこの状態でブラウザリロードを行ってみると、teams slot については@teams/default.tsxの内容が再び表示されることとなります。
ハードナビゲーションではNext.js がactive stateを追跡できず、何を表示すべきか判断がつかないため、デフォルトのコンテンツが表示されるわけですね。


/parallel-routes/settingsでブラウザリロードを行った場合にも、URLに対応するpage をもたないslot では対応するdefault.tsxの内容が表示されます。

Intercepting Routes

Parallel Routes では並行して複数のpageが描画されていましたが、Intercepting Routesでは同じroute(URL)にある別のpage の描画を阻止(インターセプト)して、代わりのpage が描画されます。
画面描画のインターセプトが発生する条件は、ソフトナビゲーションによる画面遷移で該当ページが表示されることです。
https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

Intercepting Routesの定義方法

Intercepting Routes(.)folderというようなディレクトリ名とすることによって定義します。
相対パスの../のようにどのroute のpage をインターセプトするかを指定できます。

  • (.) 同じ階層
  • (..) 1つ上の階層
  • (..)(..) 2つ上の階層
  • (...) appディレクトリのある階層

この記法もURLには影響を与えません。
(..)は1つ上の階層を指定しますが、これはファイルの構成による階層を指すのではなく、route segment 単位(/で区切られたURLの一部分のこと)での階層を指します。


以下のようなファイル構造となっていた場合、slotである@modalroute segment とならないことから、(..)photos(..)は、feedと同じ階層を指します。
ファイルの構成上は、feedディレクトリは(..)photosの2つ上の階層となっていることには注意が必要です。

.
└── app
    ├── feed
    │   └── @modal
    │       └── (..)photos
    └── photos

NextGramのModal実装

ここからはNextGram でModal がどのように実装されているかをみていきます。

ディレクトリ構成

NextGram はParallel RoutesIntercepting Routes を組み合わせた以下のディレクトリ構成でModal 表示を実現しています。
この組み合わせによって、ソフトナビゲーションの場合にはModalが表示されるが、共有したURLからのアクセスや、ブラウザリロードといったハードナビゲーションによってページを表示した場合にはModal が表示されず、Modal内に表示されるはずだったコンテンツが表示されるようなModal が実現されています。

.
└── app
    ├── @modal
    │   ├── (.)photos
    │   │   └── [id]
    │   │       ├── modal.tsx
    │   │       └── page.tsx
    │   └── default.tsx
    ├── photos
    │   └── [id]
    │       └── page.tsx
    ├── default.tsx
    ├── layout.tsx
    └── page.tsx

@modal/(.)photosの箇所でParallel RoutesIntercepting Routes が併用されています。
/photos/1などにソフトナビゲーションによるアクセスがあった場合には、(.)photos/[id]による画面描画のインターセプトが働くため、/photos/[id]/page.tsxの内容は表示されず、代わりに@modal/(.)photos/[id]/page.tsxの内容がModal内 に表示されます。

一方、ハードナビゲーションによるアクセスが行われた場合にはインターセプトは働かず、photos/[id]/page.tsxの内容が表示されます。

以上のような挙動により、URL 共有によってModal 内のコンテンツ(photos/[id]/page.tsxに対応)を共有したり、ブラウザリロードによってModal 内のコンテンツが消えてしまうことを防ぐことが可能となります(これらを実現するには、modal slot での表示内容とchildren slot での表示内容を揃えておく必要はあります)。

NextGram のroot にあるlayout.tsxにはModal 表示用のdiv 要素が配置されており、他の箇所で作成されたModal 用のJSX 要素をReact のcreatePortalによってdiv 要素に転送する形で実装されています。

layout.tsx
export default function RootLayout(props: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {props.children}
        {props.modal}
        <div id="modal-root" />
      </body>
    </html>
  );
}

createPortal

createPortalを利用することで、DOM 上の別の場所に子要素(children)をレンダーすることができるようになります。

createPortal(children, domNode, key?)
createPortalは引数に描画したい子要素childrenと描画先のDOMノードdomNodeを渡すことで、domNodeの下にchildrenを描画したReact ノードが返されます。
domNodeとして実際に渡す要素は、document.getElementById() によって取得します。
この要素はレンダー済みである必要があり、更新中の場合にはポータルが再生成されます。

createPortalで転送した要素について、イベントの伝播には注意が必要で、DOMツリーの構造に従って伝播されるのではなく、React ツリーに従って伝播されます。createPortalは引数として受け取った子要素の物理的な位置のみを変更しているわけです。

createPortalという名前から、JSX要素をワープさせるためのポータルを作成していると捉えることができますね。

https://ja.react.dev/reference/react-dom/createPortal

Modalコンポーネント

@modal/(.)photos/[id]/modal.tsx
'use client';

import { type ElementRef, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef<ElementRef<'dialog'>>(null);

  useEffect(() => {
    if (!dialogRef.current?.open) {
      dialogRef.current?.showModal();
    }
  }, []);

  function onDismiss() {
    router.back();
  }

  return createPortal(
    <div className="modal-backdrop">
      <dialog ref={dialogRef} className="modal" onClose={onDismiss}>
        {children}
        <button onClick={onDismiss} className="close-button" />
      </dialog>
    </div>,
    document.getElementById('modal-root')!
  );
}

NextGram のModalコンポーネントは<dialog> を使用して実装されています。
showModal()によってdialogを開いた状態にできますが、コンポーネントが描画された後にuseEffect内の処理によってModalを開いた状態にしています。

Modal を閉じる処理については、Modal への表示がURL によって管理できることを利用して、router.back()で1つ前のページに戻るだけでよくなっています。


この<Modal>コンポーネントで表示したい要素を囲むことで簡潔に、そして機能性に富んだModal の実装が実現されているわけですね。

GitHubで編集を提案

Discussion