이번 포스트에서는 Next.js 의 새로운 기능 중 가장 사용하고 싶었던 Intercepting Routes를 기존 미리 보기 Modal에 적용해 내용 공유해 보려 한다.
Next.js 공식문서 - intercepting-routes
Next.js version 14.0.2
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<div id="portal"></div>
</body>
</html>
);
}
export default function Home() {
const posts = mockPosts;
return (
<main>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Post post={post} />
</li>
))}
</ul>
</main>
);
}
"use client";
import Image from "next/image";
import { Post } from "@/posts";
import useModalControl from "../hooks/useModalControl";
import ModalPortal from "./ModalPortal";
import DetailPostModal from "./DetailPostModal";
type Props = {
post: Post;
};
export default function Post({ post }: Props) {
const { openModal, closeModal, isOpen } = useModalControl();
return (
<>
<article onClick={openModal}>
<h1>{post.username}</h1>
<Image src={post.imageSrc} height={600} width={600} alt={post.name} />
</article>
{isOpen && (
<ModalPortal>
<DetailPostModal postId={post.id} onClose={closeModal} />
</ModalPortal>
)}
</>
);
}
import { ReactNode } from "react";
import { createPortal } from "react-dom";
type Props = {
children: ReactNode;
};
export default function ModalPortal({ children }: Props) {
if (typeof window === "undefined") {
return null;
}
const node = document.getElementById("portal") as Element;
return createPortal(children, node);
}
"use client";
import {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
} from "react";
type Props = {
children: ReactNode;
onClose: () => void;
};
export default function Modal({ children, onClose }: Props) {
const overlay = useRef<HTMLDivElement>(null);
const wrapper = useRef<HTMLDivElement>(null);
const onClick: MouseEventHandler = useCallback(
(e) => {
if (e.target === overlay.current || e.target === wrapper.current) {
onClose();
}
},
[onClose, overlay, wrapper]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
},
[onClose]
);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalStyle;
};
}, []);
return (
<div
ref={overlay}
className="fixed z-10 left-0 right-0 top-0 bottom-0 mx-auto bg-black/60"
onClick={onClick}
>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2
-translate-y-1/2 w-full sm:w-10/12 md:w-8/12 lg:w-1/2 p-6"
ref={wrapper}
>
{children}
</div>
</div>
);
}
import Link from "next/link";
import DetailPost from "./DetailPost";
import Modal from "./Modal";
type Props = {
postId: string;
onClose: () => void;
};
export default function DetailPostModal({ postId, onClose }: Props) {
return (
<Modal onClose={onClose}>
<DetailPost postId={postId} />
<Link href={`/posts/${postId}`}>To detail Page</Link>
</Modal>
);
}
Next.js 공식문서 - intercepting-routes
Next.js 공식문서 - parallel-routes
제일 큰 변화!!
layout에서 해당 Parallel Routes를 인식하지 못해 아래와 같은 버그가 계속 나온다...ㅎ
내가 사용하는 url은 /posts/[postId]
였기 때문에 위와 같은 디렉토리 구조가 나왔다.
import DetailPostModal from "@/app/component/DetailPostModal";
type Props = {
params: {
postId: string;
};
};
export default function page({ params }: Props) {
return <DetailPostModal postId={params.postId} />;
}
import "./globals.css";
type Props = {
children: React.ReactNode;
modal: React.ReactNode;
};
export default function RootLayout({ children, modal }: Props) {
return (
<html lang="en">
<body>
{children}
{modal}
</body>
</html>
);
}
import mockPosts from "@/posts";
import Post from "./component/Post";
import Link from "next/link";
export default function Home() {
const posts = mockPosts;
return (
<main>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/posts/${post.id}`}>
<Post post={post} />
</Link>
</li>
))}
</ul>
</main>
);
}
import Image from "next/image";
import { Post } from "@/posts";
type Props = {
post: Post;
};
export default function Post({ post }: Props) {
return (
<article>
<h1>{post.username}</h1>
<Image src={post.imageSrc} height={600} width={600} alt={post.name} />
</article>
);
}
"use client";
import {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
} from "react";
import { useRouter } from "next/navigation";
type Props = {
children: ReactNode;
};
export default function Modal({ children }: Props) {
const router = useRouter();
const overlay = useRef<HTMLDivElement>(null);
const wrapper = useRef<HTMLDivElement>(null);
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]);
useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalStyle;
};
}, []);
return (
<div
ref={overlay}
className="fixed z-10 left-0 right-0 top-0 bottom-0 mx-auto bg-black/60"
onClick={onClick}
>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2
-translate-y-1/2 w-full sm:w-10/12 md:w-8/12 lg:w-1/2 p-6"
ref={wrapper}
>
{children}
</div>
</div>
);
}
개발하다 보면 1개의 클릭이 1개의 변화가 크게 느껴지지 않지만,
사용자들은 변화하는 화면에 큰 장벽을 느낀다. 1번의 클릭과 2번의 클릭으로 이동하는 페이지가 얼마나 큰 차이를 가져오는지 다들 알지 않은가. 1번의 클릭은 1개의 계단을 오르는 것 아니 그보다 더 큰 차이일 수 있다.
유튜브에서도 최근에 이 부분을 가지고 작지만 큰 변화를 만들어냈다.
업데이트가 된 지 좀 되긴 했지만, 유튜브는 원래 해당 영상을 클릭해야 볼 수 있었다. 아니면 초기 몇 초만 보이는 정도였던 적도 있었던 것 같다. 하지만 업데이트 후 영상 끝까지는 리스트 화면에서 볼 수 있게 되었다. 소리까지도 들으면서.
이 기능이 나왔을 때 "굳이? 이렇게 할 필요가 있을까? 들어가서 보면 되지"라는 생각이 들었었다. 하지만 사용해 보니, 20분, 30분이 되는 영상을 무심결에 리스트 화면에서 끝까지 보게 되는 나를 확인할 수 있었다.
어떻게 생각해 보면 한 번의 클릭이 몇 초가 걸리는 것도 아니고 더 큰 화면과 더 많은 정보와 함께 볼 수 있는데 큰 차이일까 했지만.
0.0 몇 초의 장벽은 있지만 이전보다 훨씬 낮아지 장벽으로 사람들이 더 다양한 컨텍츠를 접하고 무심결에 더 빠져들도록 만들었다고 나는 생각한다.
Intercepting Routes도 그것처럼 사용자들에게 훨씬 낮은 장벽으로 더 많은 컨텐츠를 보여줄 수 있는 좋은 기능이라고 생각한다.
이 기능은 다 같이 이렇게 편리하게 사용할 수 있다니... 참 개발은 정말 멋지다!