logo

AppRouterでモーダルを実装する

2023-11-01
a year ago

開発環境

  • next 13.5.4

前提

Parallel RoutingなどApp Routerの機能を利用しています。

本題

Nextjs公式ドキュメントにParallel RoutingIntercepting 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のページ版の実装です。

ここが従来の表示/非表示を状態管理をしながら実装していた一般的なモーダルの実装と異なるところです。

モーダルのルートをインターセプトすると、現在のレイアウト内のアプリケーションの別の部分からルートを読み込むことができます。


ただし、共有可能な URL をクリックするか、ページを更新して写真に移動する場合は、モーダルではなく写真ページ全体がレンダリングされる必要があります。ルートの遮断は発生してはいけません。


動作としては、仮に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が発表されているという。。

進化が早い・・

参照