React Portal을 사용한 Modal 컴포넌트 생성
React Portal을 사용해 모달을 띄워보자
우선 루트 레이아웃에 모달을 넣을 div를 넣어놓았다. (id="modal-root")
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="kr">
<body className={`layout ${pretendard.className}`}>
<div id="modal-root" />
<main>{children}</main>
</body>
</html>
);
}
그리고 Portal이라는 컴포넌트를 따로 만들어서 모달을 children으로 받고, 이 모달을 위에 넣어놓은 id modal-root의 div로 이동시킨다.
'use client';
import { PropsWithChildren, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
const Portal = ({ children }: PropsWithChildren) => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted ? createPortal(children, document.getElementById('modal-root') as HTMLDivElement) : null;
};
export default Portal;
만약 여기서 만약 컴포넌트의 렌더여부(isMounted)를 확인하지 않으면, 다음과 같은 에러가 나타난다.
Nextjs 서버에서 먼저 렌더링된 다음에 클라이언트로 전송되기 때문에, document나 window같은 클라이언트 사이드에서만 사용할 수 있는 전역 변수는 렌더링이 된 후에 사용되어야 한다.
따라서 isMounted라는 상태 변수를 하나 만들어서 렌더링이 된 후에 createPortal을 사용해 모달을 이동시키도록 했다.
프로젝트에서 여러 모달의 기본틀은 거의 같았기 때문에 다음과 같이 Modal이라는 컴포넌트를 만들고 기본 스타일링을 해놓았다.
(애니메이션은 Framer Motion을 사용했고, 스타일링은 여기서는 생략)
그래서 위에서 만든 Portal 컴포넌트의 children으로 모달의 내용을 넣어주도록 구현했다.
// Modal.tsx
const Modal = ({ isOpen, onClose, children }: PropsWithChildren<ModalProps>) => {
return (
<Portal>
<AnimatePresence>
{isOpen && (
<>
<Backdrop>
<motion.div
// 생략
>
{children}
</motion.div>
</Backdrop>
</>
)}
</AnimatePresence>
</Portal>
);
};
모달 외부 클릭 시 모달 닫기(useOutsideClick 훅)
모달의 외부를 클릭했을 때 자동으로 닫히기 위해 useOutsideClick이라는 커스텀 훅을 만들었다.
document에 mousedown과 touchstart 이벤트를 등록해서 클릭한 요소를 확인하도록 한다.
이때 클릭한 요소가 Ref로 지정한 요소가 아니라면(바깥 지점을 클릭) 모달을 닫는 함수 close를 호출해 모달을 닫는 로직으로 구성되어있다.
그리고 unmount될 때 등록한 event들을 지우도록 한다.
import { RefObject, useEffect } from 'react';
const useOutsideClick = (ref: RefObject<HTMLElement>, close: () => void) => {
useEffect(() => {
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('touchstart', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('touchstart', handleOutsideClick);
};
}, [ref]);
};
export default useOutsideClick;
사용은 다음과 같이하면 된다.
const Modal = ({ isOpen, onClose, children, isRecommendationModal = false }: PropsWithChildren<ModalProps>) => {
const modalRef = useRef(null);
useOutsideClick(modalRef, onClose);
return (
<Portal>
<AnimatePresence>
{isOpen && (
<Backdrop>
<motion.div
ref={modalRef}
// 생략
>
{children}
</motion.div>
</Backdrop>
)}
</AnimatePresence>
</Portal>
);
};
'Next.js' 카테고리의 다른 글
[Next.js] 폰트 최적화, layout shift 발생, next 로컬 폰트 (0) | 2024.04.21 |
---|---|
[Next.js] Learn - Ch3. 폰트와 이미지 최적화 (next/font, next/image) (0) | 2024.04.19 |
[Nextjs] 사전 렌더링 작동 방식 (1) | 2023.10.02 |
[Nextjs] 파일 기반 라우팅 (0) | 2023.10.01 |
Data Fetching (0) | 2023.08.26 |