【Next.js】Intercepting Routes + Parallel RoutesでModalを扱う
はじめに
Next.jsのApp Routerが使えるようになってしばらく経ちますが、Intercepting Routes + Parallel Routesを使ったModalの扱いがとても便利だったので、こちらに残したいと思います。
やりたいこと
シンプルなToDoアプリを今回の対象とします。
この時、以下のような挙動を満たしたいとします。
- 一覧にあるいずれかのToDoをクリックするとToDoの編集ダイアログが開く。更新やキャンセルを押す、またはブラウザバックすることで、このダイアログは閉じて元の画面に戻る
- 編集ダイアログを開いた時のURLに直接アクセスした場合、ダイアログではなく編集ページが開く
こういったケースを実現する際に、Intercepting Routes + Parallel Routesを使った方法が非常に便利だったため、ここで紹介したいと思います。
Parallel Routes
Parallel RoutesはNext.jsのApp Routerで利用できる機能となります。
この機能を利用することで、現在のページの状態を維持しつつ、サブページ(Slot)としてModalを開くことが可能となります。別の言い方をすると、/todo/[i]
というパスに遷移した時、/todo/page.tsx
の表示状態を維持したまま、/todo/[id]/page.tsx
の内容を表示させることが可能となります。
この機能を用いることで、Modalを開いた時の状態をURLパスとして保持することが可能となります。すなわち、ブラウザバックやブラウザフォワードでModalを開閉することが可能になります。
但し、この機能のみだとModal表示中にリロードした際、元の状態が再現できなくなります(※)。リロードや直リンクでアクセスした際に、編集画面に遷移させるためには次のIntercepting Rotes利用します。
※リロードなどのHard Navigationでは、/todo/page.tsx
の状態が失われるので、/todo/default.tsx
or 404にフォールバックされる
Parallel Routesの詳細については、別の記事でも紹介していますのでご参照ください🙇
Intercepting Routes
Intercepting Routesも、Next.jsのApp Routerで利用できる機能となります。
こちらは、Linkなどで遷移した際に、本来表示されるべきページとは別のページを表示させることが可能になります。具体的には、(.)[id]
のようなディレクトリを/todo/[id]
と同じ階層に置くことで/todo
ページなどから/todo/[id]
に遷移した時に、/todo/[id]/page.tsx
ではなく、/todo/(.)[id]/page.tsx
がrenderされることになります。
app/todo
├── page.tsx
├── [id]
│ ├── page.tsx
├── (.)[id]
│ ├── page.tsx ←/todo/[id]をinterceptする
※ (.)はInterceptする対象が同じパス階層(ディレクトリ階層ではない)にあることを示します。一つ上の階層をInterceptしたい場合(..)を用います
一度/todo/(.)[id]/page.tsx
がrenderされた後にページのリロードや直接アクセスを行うと、/todo/[id]/page.tsx
がrenderされるようになります。つまり今回の場合、リロードすると非Modalの編集ページが表示されることになります。
実装
上で紹介した2つの機能を用いて、今回のModalを実装していきたいと思います。
まず、ファイル構成としては以下のようにファイルを用意します。
app/todo
├── page.tsx
├── layout.tsx
├── [id]
│ ├── page.tsx
└──── @modal
├── (.)[id]
│ └─── page.tsx
└──── default.tsx
/todo
以下の各ファイルの役割の概要は以下のとおりです。
ファイル | 役割 |
---|---|
layout.tsx | Parallel Routesを利用するため、page.tsxと@modalのSlotをrenderする |
[id]/page.tsx | 非Modal用のToDo編集ページ。直接アクセスされた際に表示 |
@modal/(.)[id]/page.tsx | Modal用のToDo編集ページ。ページ内のリンク経由でアクセスされた際に表示 |
@modal/default.tsx | @modal Slotがパスマッチしない時に表示するサブページ。Modalが開かれていないときは基本的に何も表示しないので、基本的にnullを返すだけのコンポーネント |
以下では、各ファイルのポイントについて概説したいと思います。
layout.tsx
@modalのSlotをlayout.tsxにてrenderします。
type Props = {
children: React.ReactNode;
modal: React.ReactNode;
}
const Layout: FC<Props> = ({ children, modal }) => {
return (
<div className='w-screen'>
{children}
{modal}
</div>
);
}
これでParallel Routeによって、同じレイアウト内で/todo
, /@modal/**
をrenderすることが可能になります。
@modal/(.)[id]/page.tsx
Modalで表示する際のページになります。
...
const EditTodoModal: FC<Props> = ({ todo }) => {
const router = useRouter();
return (
<Dialog
modal
open
onOpenChange={(isOpen) => {
if (!isOpen) {
router.back()
}
}}
>
<DialogContent>
...
</DialogContent>
</Dialog>
);
}
const Page: FC<Props> = async ({ params }) => {
const todo = await getTodo(Number(params.id))
return <EditTodoModal todo={todo} />;
}
export default Page;
ポイントとしては、Dialogについてはopen状態のstateを持っておらず、初回レンダー時にopen(=true)を渡しています。Modalを閉じる時には、open=falseにするのではなく、router.back()
する必要があります。
[id]/page.tsx
こちらは、非Modalの編集ページになります。通常のNext.jsのpageコンポーネントと同様に実装する形で問題ありません。
const Page: FC<Props> = async ({ params }) => {
const todo = await getTodo(Number(params.id))
return (
<form className='max-w-[700px] p-10 flex flex-col gap-2'>
...formの中身を実装
</form>
);
}
export default Page;
@modal/default.tsx
こちらはSlotがパスマッチしない時に表示されるページとなります。具体的には、Modalを開いていない時=パスが/todo
や/todo/[id]
の時に表示されることになります。
基本的には、何も表示する必要はないためnullを返します。
export default function Default() {
return null
}
ポイントとなる実装は以上となります。
最後に
ここまで読んでいただきありがとうございます。
今回紹介したコードの完全版については以下で公開しています。
(今回はModalの挙動確認が目的のため、更新処理などは実装していません)
Discussion