[Next.js] 기존 미리보기 Modal 대신 Intercepting Routes 적용하기

Maria Kim·2023년 11월 13일
0

이번 포스트에서는 Next.js 의 새로운 기능 중 가장 사용하고 싶었던 Intercepting Routes를 기존 미리 보기 Modal에 적용해 내용 공유해 보려 한다.

Next.js 공식문서 - intercepting-routes

개발 환경

Next.js version 14.0.2

기존 코드

디렉토리 구조

간략한 코드 소개

layout.tsx

import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <div id="portal"></div>
      </body>
    </html>
  );
}
  • portal을 body에 바로 붙이지 않고 portal 영역을 지정해 사용하도록 구역을 정해준다.

page.tsx

export default function Home() {
  const posts = mockPosts;

  return (
    <main>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Post post={post} />
          </li>
        ))}
      </ul>
    </main>
  );
}

component/Post.tsx

"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>
      )}
    </>
  );
}
  • Modal의 open 상태를 이용하여 Modal open 유무를 관리하고 있음을 확인 할 수 있다.

component/ModalPortal.tsx

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);
}
  • 현재 modal의 경우 서버 상에서 만들어질 필요가 없기 때문에 서버 상에서 만들어지지 않도록 조건문을 넣었다.

component/Modal.tsx

"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>
  );
}
  • 현재는 어떤 모달을 닫아야 하는지 알아야 하기 때문에 onClose를 props로 받아 사용함을 알 수 있다.

component/DetailPostModal.tsx

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

디렉토리 구조

제일 큰 변화!!

  • 우선 제일 중요한 Parallel Routes를 이용해서 modal 영역 폴더를 만든다.

⚠️ Parallel Routes 주의사항 ⚠️

  • Parallel Routes를 사용 시 1개의 Parallel Route가 위 디렉토리에 존재한다면 최상단에는 default.js 파일이 있더라도 무조건 page.js가 null을 리턴하더라도 하나 있어야 한다.
  • 아니면 2개 이상의 Parallel Route가 존재한다면 최소한 1개의 Parallel Route는 최상단에 page.js 폴더가 있어야 한다. 아니면 인식을 못 해 버그가 난다.
    (... 수많은 삽질의 시간이 있어지만 그래도 해결했다면 다행이겠지...ㅎ 🥹 )

layout에서 해당 Parallel Routes를 인식하지 못해 아래와 같은 버그가 계속 나온다...ㅎ


상세 코드

내가 사용하는 url은 /posts/[postId]였기 때문에 위와 같은 디렉토리 구조가 나왔다.

@modal/(.post)/[postId]/page.tsx
import DetailPostModal from "@/app/component/DetailPostModal";

type Props = {
  params: {
    postId: string;
  };
};

export default function page({ params }: Props) {
  return <DetailPostModal postId={params.postId} />;
}
  • 여기에서 preview에서 보여줄 modal을 만들면 된다.
    상세 페이지와 preview 페이지를 다르게 하지만 편하게 만들 수 있다는 것!!

layout.tsx

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>
  );
}
  • modal을 사용하고 싶은 영역에 위치하면 된다.

page.tsx

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>
  );
}
  • 이전과 다르게 Link가 들어가 있는 것을 확인할 수 있다.
    링크지만 클릭해도 Intercepting Routes로 적용되기 때문에 현재 링크만 변화하지 상세 페이지로 이동하지는 않는다.

post.tsx

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>
  );
}
  • 이전과 다르게 이제 modal과 modal의 열림 상태를 관리하지 않아도 된다. 해당 코드가 모두 제거됐다.

component/Modal.tsx

"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>
  );
}
  • onClose 를 주입받지 않아도 되고 router로 이전 페이지로 이동하면 modal 닫기처럼 행동한다.

후기

개발하다 보면 1개의 클릭이 1개의 변화가 크게 느껴지지 않지만,
사용자들은 변화하는 화면에 큰 장벽을 느낀다. 1번의 클릭과 2번의 클릭으로 이동하는 페이지가 얼마나 큰 차이를 가져오는지 다들 알지 않은가. 1번의 클릭은 1개의 계단을 오르는 것 아니 그보다 더 큰 차이일 수 있다.

유튜브에서도 최근에 이 부분을 가지고 작지만 큰 변화를 만들어냈다.
업데이트가 된 지 좀 되긴 했지만, 유튜브는 원래 해당 영상을 클릭해야 볼 수 있었다. 아니면 초기 몇 초만 보이는 정도였던 적도 있었던 것 같다. 하지만 업데이트 후 영상 끝까지는 리스트 화면에서 볼 수 있게 되었다. 소리까지도 들으면서.

이 기능이 나왔을 때 "굳이? 이렇게 할 필요가 있을까? 들어가서 보면 되지"라는 생각이 들었었다. 하지만 사용해 보니, 20분, 30분이 되는 영상을 무심결에 리스트 화면에서 끝까지 보게 되는 나를 확인할 수 있었다.

어떻게 생각해 보면 한 번의 클릭이 몇 초가 걸리는 것도 아니고 더 큰 화면과 더 많은 정보와 함께 볼 수 있는데 큰 차이일까 했지만.

0.0 몇 초의 장벽은 있지만 이전보다 훨씬 낮아지 장벽으로 사람들이 더 다양한 컨텍츠를 접하고 무심결에 더 빠져들도록 만들었다고 나는 생각한다.

Intercepting Routes도 그것처럼 사용자들에게 훨씬 낮은 장벽으로 더 많은 컨텐츠를 보여줄 수 있는 좋은 기능이라고 생각한다.

이 기능은 다 같이 이렇게 편리하게 사용할 수 있다니... 참 개발은 정말 멋지다!

참고

next.js NextGram Github

profile
Frontend Developer, who has business in mind.

0개의 댓글