Zenn
📈

Next.jsのParallel Routesでモーダルを管理する

2025/03/26に公開

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

Next.jsのParallel Routesを活用した新しいアプローチ

Web開発をしていると、モーダル(Modal) を実装する機会は本当に多いですよね。
一見シンプルに見えるこのUI要素も、実際には意外とやっかいな課題を伴うことが多いものです。

「モーダルが開いた状態でリロードしたらどうなるのか?」
「戻るボタンを押したとき、モーダルは自然に閉じるのか?」
こんな悩み、一度は考えたことがあるのではないでしょうか。

従来の方法(Stateベース)の問題点

const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);

多くの場合、useStateでモーダルの開閉を管理する構造を作ると思います。
しかし、この方法には以下のような限界があります。

  1. リロード(F5)するとモーダルが消えてしまう
     ユーザーが誤ってリロードすると、操作の流れが途切れてしまいます。

  2. モーダルが開いている状態のURLを共有できない
     その場限りの状態なので、リンクとして送れません。

  3. ブラウザの戻るボタンの処理が複雑
     モーダルの状態と履歴の整合性を保つのが難しくなります。

  4. モーダル内で別ページに遷移後、戻るとモーダルも閉じてしまう
     ユーザーにとって直感的でない動作になることがあります。

また、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

/app/page.tsx
'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>
  );
}
/app/layout.tsx
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>
  );
}
/app/@modal/modal-a/page.tsx
'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>
  );
}
/app/@modal/modal-a/page.tsx
'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は、「モーダルをより宣言的に、よりネイティブっぽく」実装できる強力な手段です。
ただし万能ではありません。プロジェクトの要件やユースケースに応じて、
従来の方法とうまく使い分けることが重要です。

Bizlink Developers Blog

Discussion

ログインするとコメントできます