TL;DR

  • 開閉を制御する汎用的なモーダルコンポーネントを作る。
  • Parallel Routes と Intercepting Routes を組み合わせると、モーダルを開いたときに URL が変わり、元のページは裏に表示されたままになる。

内容

汎用的なモーダルコンポーネント

開閉を制御するコンポーネント ModalContainer を定義する。children はクリックするとモーダルを開く要素で、content は開いているときに表示する内容である。content はモーダルを閉じる関数を受け取る。

type Props = {
  children: ReactNode // クリックされたときにモーダルを開く要素
  content: (closeModal: () => void) => ReactNode // 開いている状態のときに表示するコンテンツ
  defaultOpen?: boolean // デフォルトでモーダルが開いているか
  toggleClassName?: string
}
 
export function ModalContainer({ children, content, defaultOpen = false, toggleClassName }: Props) {
  const { openModal, closeModal, isOpen } = useModal(defaultOpen)
 
  return (
    <>
      <button onClick={openModal} className={clsx(styles.toggle, toggleClassName)}>
        {children}
      </button>
 
      {isOpen && content(closeModal)}
    </>
  )
}

呼び出す側は Client Component で、いいねの状態などを持ち、content に表示したいモーダルを渡す。

"use client"
 
// ...
 
type Props = {
  photo: Photo
  children: ReactNode
}
 
export function PhotoViewModalContainer({ photo: { likedCount, ...photo }, children }: Props) {
  const [liked, setLiked] = useState(false)
  const [localLikedCount, setLocallikedCount] = useState(likedCount)
 
  const handleClickLike = (count: number) => {
    setLiked(true)
    setLocallikedCount(count)
  }
 
  return (
    <ModalContainer
      content={(closeModal) => (
        <PhotoViewModal
          {...photo}
          likedCount={localLikedCount}
          liked={liked}
          close={closeModal}
          onClickLike={handleClickLike}
        />
      )}
    >
      {children}
    </ModalContainer>
  )
}

Parallel Routes と Intercepting Routes を用いた実装

2 つを組み合わせると、モーダルを開いたときに URL が更新され(Intercepting Routes)、元のページは裏側に表示されたままになる(Parallel Routes)。これで、モーダルを作るときによくある次の課題を解決できる。

  • モーダルの URL を共有できる
  • ページを再読み込みしても、モーダルを閉じずにコンテキストを保てる
  • ブラウザで戻ったときは、前のルートに戻るのではなくモーダルを閉じる
  • 進んだときは、モーダルをもう一度開く

ディレクトリ構成は次のとおりである。

app/(site)
  layout.tsx                 // slot(@modal)を Props で受け取る
  @modal/                    // モーダルと元のページを重ねて表示(Parallel Routes)
    default.tsx
    (.)photos/[photoId]/view // ソフトナビゲーション時に表示(Intercepting Routes)
  photos/[photoId]/view      // ハードナビゲーション時に表示

レイアウトは、@modal のスロットを modal という Props で受け取り、children と並べて描画する。

// app/(site)/layout.tsx
type Props = {
  children: React.ReactNode
  modal: React.ReactNode
}
 
export default async function SiteLayout({ children, modal }: Props) {
  return (
    <>
      {/* ... */}
      {children}
      {modal}
      {/* ... */}
    </>
  )
}

default.tsx は、一致する Route Segment がないときにスロットに表示するフォールバックの UI である。モーダルを出さないときは null を返す。

// app/(site)/@modal/default.tsx
export default function Page() {
  return null
}

公式ドキュメントにも、この組み合わせでモーダルを作る例がある(Intercepting Routes: Modals)。

キーボードイベントを捉えるカスタムフック

モーダルで開いた写真は、キーボードで別の写真に切り替えられる。サンプルコードでは、指定したキーが押されたときにコールバックを呼ぶカスタムフック useKey を使っている。

// useKey.ts
import { useEffect, useRef } from "react"
 
export function useKey(code: string, cb: (event: KeyboardEvent) => void) {
  const cbRef = useRef(cb)
  cbRef.current = cb
  useEffect(() => {
    const handleKeyPress = (event: KeyboardEvent) => {
      if (event.code === code) {
        cbRef.current(event)
      }
    }
    window.addEventListener("keydown", handleKeyPress)
    return () => {
      window.removeEventListener("keydown", handleKeyPress)
    }
  }, [code])
}

useEffect で window に keydown のリスナを登録し、押されたキーの event.code が引数の code と一致したときだけコールバックを呼ぶ。クリーンアップ関数でリスナを外すので、コンポーネントがアンマウントされるとリスナも消える。

コールバックはレンダリングのたびに useRef の cbRef.current へ入れ直し、依存配列には code だけを渡している。コールバックが作り直されてもリスナは登録し直されず、呼ばれるのは常に最新のコールバックになる。

使うときは、キーのコードとコールバックを渡す。次は、左矢印キーで前の写真のモーダルに移動する例である。

useKey("ArrowLeft", () => {
  router.replace(`/photos/${prevPhotoId}/view`, { scroll: false })
})

キーワード