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 })
})