Next.jsのParallel Routesでモーダルを管理する
Next.jsのParallel Routesを活用した新しいアプローチ
Web開発をしていると、モーダル(Modal) を実装する機会は本当に多いですよね。
一見シンプルに見えるこのUI要素も、実際には意外とやっかいな課題を伴うことが多いものです。
「モーダルが開いた状態でリロードしたらどうなるのか?」
「戻るボタンを押したとき、モーダルは自然に閉じるのか?」
こんな悩み、一度は考えたことがあるのではないでしょうか。
従来の方法(Stateベース)の問題点
const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
多くの場合、useStateでモーダルの開閉を管理する構造を作ると思います。
しかし、この方法には以下のような限界があります。
-
リロード(F5)するとモーダルが消えてしまう
ユーザーが誤ってリロードすると、操作の流れが途切れてしまいます。 -
モーダルが開いている状態のURLを共有できない
その場限りの状態なので、リンクとして送れません。 -
ブラウザの戻るボタンの処理が複雑
モーダルの状態と履歴の整合性を保つのが難しくなります。 -
モーダル内で別ページに遷移後、戻るとモーダルも閉じてしまう
ユーザーにとって直感的でない動作になることがあります。
また、AndroidのWebView環境では物理的なバックボタンにより、意図せずページ全体が戻ってしまうという問題も発生することがあります。
新しいアプローチ:Next.js Parallel Routes
Next.js 13から導入された Parallel Routes 機能を活用することで、これらの問題を大きく解消することができます。
Parallel Routesは、別のURL状態 を通じてモーダルを構成し、戻る/進むといったブラウザの履歴ナビゲーションとも自然に連携 することが可能です。
ディレクトリ構成
/app
/@modal
/modal-a/page.tsx
/modal-b/page.tsx
/page.tsx
ブラウザの「戻る」ボタンを押すと、モーダルが自動的に閉じられます。
メリット
- モーダルの状態を別途管理する必要がない(useState、Zustand、Recoil など不要)
- 戻るボタンで自然にモーダルが閉じる
- Android WebViewの物理的なバックボタンにも対応可能
- コードがよりクリーンで宣言的になる
デメリット
- リロード時に404が発生する
Parallel Slot(@modal)は直接アクセス(ハードナビゲーション)をサポートしていません。
例えば /modal-a に直接アクセスすると、404エラーになります - URLを直接入力してのアクセスは不可
Parallel Slotは <Link> や router.push() など、ソフトナビゲーションのみで動作します。
アドレスバーに入力したり、リロードでアクセスする場合は、上記のインターセプトルートが必要です。
→ これを解決するには、Parallel Slot(@modal) をトリガーできる
インターセプトルート(Intercepting Route) を追加する必要があります。
-
ネストモーダルが難しい
Parallel Slotは基本的に1つしか管理できないため、
モーダル内にさらにモーダルを表示する(ネスト)には、別途状態管理や Slot Pattern の導入が必要です。 -
SSR(サーバーサイドレンダリング)時の注意点
サーバー側で描画する際に、Parallel Slotが空の状態となり、初期画面でエラーが発生する可能性があります。
特にダイナミックルーティングや static export を利用している場合は注意が必要です。
Sample
'use client';
import { useRouter } from 'next/navigation';
export default function HomePage() {
const router = useRouter();
return (
<main className='min-h-screen flex flex-col items-center justify-center bg-gray-50 text-gray-900 p-6'>
<h1 className='text-3xl font-extrabold mb-6'>モーダルデモページ</h1>
<div className='flex gap-4'>
<button onClick={() => router.push('/modal-a')} className='px-6 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition'>
モーダル A を開く
</button>
<button onClick={() => router.push('/modal-b')} className='px-6 py-2 rounded bg-green-600 text-white hover:bg-green-700 transition'>
モーダル B を開く
</button>
</div>
</main>
);
}
import './globals.css';
// src/app/layout.tsx
export default function RootLayout({ children, modal }: { children: React.ReactNode; modal: React.ReactNode }) {
return (
<html lang='ja'>
<body>
{children}
{modal}
</body>
</html>
);
}
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function ModalA() {
const router = useRouter();
return (
<div className='fixed inset-0 bg-black/50 flex items-center justify-center z-50'>
<div className='bg-white p-8 rounded-2xl shadow-xl w-96 text-center'>
<h2 className='text-xl font-bold mb-3'>モーダル A</h2>
<p className='mb-6 text-sm text-gray-600'>これはモーダルAのコンテンツです。</p>
<div className='flex flex-col gap-3'>
<button onClick={() => router.back()} className='px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition'>
閉じる
</button>
<Link href='/modal-b'>
<button className='px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition'>モーダル B へ</button>
</Link>
</div>
</div>
</div>
);
}
'use client';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function ModalB() {
const router = useRouter();
const handleClose = () => {
router.back();
};
const handleCloseAll = () => {
window.location.href = '/';
};
return (
<div className='fixed inset-0 bg-black/50 flex items-center justify-center z-50'>
<div className='bg-white p-8 rounded-2xl shadow-xl w-96 text-center'>
<h2 className='text-xl font-bold mb-3'>モーダル B</h2>
<p className='mb-6 text-sm text-gray-600'>これはモーダルBのコンテンツです。</p>
<div className='flex flex-col gap-3 mb-4'>
<button onClick={handleClose} className='px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 transition'>
戻る
</button>
<button onClick={handleCloseAll} className='px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition'>
ホームに戻る
</button>
</div>
<Link href='/modal-a'>
<button className='px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition'>モーダル A へ</button>
</Link>
</div>
</div>
);
}
この方式を使うべきタイミングは?
- シンプルなモーダル、戻るボタンとの自然なUXが必要な場合:→ Parallel Routes
- ネストされたモーダルが必要な場合:→ 従来のstateベースの方法
- URL共有が求められるモーダルの場合:→ 別途カスタム対応が必要
- リロードに強い実装が求められる場合:→ Parallel Routesは不安定な場合があるため注意
結論
Parallel Routesは、「モーダルをより宣言的に、よりネイティブっぽく」実装できる強力な手段です。
ただし万能ではありません。プロジェクトの要件やユースケースに応じて、
従来の方法とうまく使い分けることが重要です。
Discussion