AppRouterでモーダルを実装する
開発環境
- next 13.5.4
前提
Parallel RoutingなどApp Routerの機能を利用しています。
本題
Nextjs公式ドキュメントにParallel RoutingやIntercepting Routesを利用したモーダルの実装例があったので実装してみます。
最終的なディレクトリ構成は以下のようになります。
.
└── app
├── layout.tsx
├── default.tsx
├── @modal
| ├── (.)users
| | └── page.tsx
| └── default.tsx
├── users
| └── page.tsx
└── articles
└── page.tsx
./app/layout.tsx
export default function AppLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
Parellel Routesでは@modal のように先頭に @ が付けられた名前がスロットの名前として使用されます。この @modal スロットを同じ階層にあるLayoutコンポーネントのPropsとして受け取ります。
./app/@modal/(.users)/page.tsx
import { Modal } from '@/components/modal';
export default function UsersModal() {
return (
<Modal>
<div>Users List</div>
</Modal>
);
}
モーダルが表示された際のコンポーネントをここで実装します。
Modalコンポーネント
'use client';
import { useCallback, useRef, useEffect, type MouseEventHandler } from 'react';
import { useRouter } from 'next/navigation';
export const Modal = ({ children }: { children: React.ReactNode }) => {
const overlay = useRef(null);
const wrapper = useRef(null);
const router = useRouter();
const onDismiss = useCallback(() => {
router.back();
}, [router]);
const onClick: MouseEventHandler = useCallback(
(e) => {
if (e.target === overlay.current || e.target === wrapper.current) {
if (onDismiss) onDismiss();
}
},
[onDismiss, overlay, wrapper],
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss],
);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
return (
<div
ref={overlay}
className="fixed inset-0 z-10 mx-auto bg-black/60"
onClick={onClick}
>
<div
ref={wrapper}
className="absolute left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 rounded bg-white sm:w-10/12 md:w-8/12 lg:w-1/2"
>
<div className="p-6">{children}</div>
</div>
</div>
);
};
Nextjsが公式で公開しているレポジトリから拝借しました。
./app/users/page.tsx
export default function UsersPage() {
return (
<div>Users List</div>
);
}
モーダルの実装をしたUsersModalのページ版の実装です。
ここが従来の表示/非表示を状態管理をしながら実装していた一般的なモーダルの実装と異なるところです。
モーダルのルートをインターセプトすると、現在のレイアウト内のアプリケーションの別の部分からルートを読み込むことができます。
公式ドキュメントから引用
動作としては、仮にarticlesページからUsersModalを呼び出した時、モーダルが開いた状態でページリロードすると、UsersPageに遷移します。
アプリケーション内でページ遷移したときはモーダル(./app/@modal/(.users)/page.tsx)が優先して解決し、リロードなどが発生した場合はページ(./app/users/page.tsx)が優先されている動きになります。
以下のページでモーダルを呼び出しています。
./app/articles/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/button';
import { Link } from '@/components/link';
export default function ArticlesPage() {
const router = useRouter();
return (
<div>
<h1>Articles Page</h1>
<Link href={'/users'} scroll={false}>
Linkを利用したモーダルの呼び出し
</Link>
<Button
onClick={() => router.push('/users', { scroll: false })}
>
Buttonを利用したモーダルの呼び出し
</Button>
</div>
);
}
また、default.tsxを配置している理由はモーダルがアクティブでない時にコンテンツがレンダリングされないようにするために実装しています。
export default function Default() {
return null
}
さいごに
個人的には、これまで対応できていなかったモーダル コンテンツのURL経由での共有ができるようになったことは嬉しいです。
ただ、執筆時点(2023/11/01)でもうv14が発表されているという。。
進化が早い・・