createPortal 이란?
https://react.dev/reference/react-dom/createPortal
리액트에서 제공하는 메서드로써 UI요소를 DOM 계층 구조에서 분리된 다른 DOM 노드로 렌더링 할 때 사용되는 메서드입니다.
일반적으로 모달,드랍다운메뉴,툴팁 등 과 같이 현재 컴포넌트와 관련이 없는 DOM 요소에 UI를 렌더링 할 때 사용되는 메서드입니다.
구현 비하인드...😃
프로젝트 초창기 역할분담을 했는데 제가 아닌 다른팀원분이 모달이 구현해 주기로 하셨습니다.
하지만 모달을 맡았던 팀원분이 구현을 해주셨지만 중간에 하차를 하게되었습니다... 😇 이후 회의에서 이중모달, 반응형 기능이 추가되었고 기존에 만들었던 모달에서는 이중모달시 body의 overflow가 hidden이 안 되는 버그 및 반응형이 구현이 안되어있었습니다.
따라서 제가 완전히 모달을 뜯어고치는(?) 리팩토링을 진행하였습니다.
구현을 시작해보자 !
import type { Metadata } from 'next';
import Sidebar from '@/components/pages/main/Sidebar/Sidebar';
export const metadata: Metadata = {
title: 'pawpaw | Home',
description: '우리 반려동물을 위한 최적의 커뮤니티',
};
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<div id="modal-root" />
{children}
</div>
);
}
현재 사이드 프로젝트는 Nextjs 13 버전 및 app 디렉터리로 진행되었습니다.
createPortal 메서드는 두 번째 인자로 어디에 UI를 렌더링 할지 인자를 받습니다. 따라서 다른 컴포넌트의 스타일링에 겹치지 않게 하기 위해 메인루트레이아웃의 modal-root를 id로 가지는 div를 추가해 주었습니다.
return createPortal(
<>
{open && (
<Overlay onClose={onClose}>
<FocusLock>
<ModalWrapper>{children}</ModalWrapper>
</FocusLock>
</Overlay>
)}
</>,
modalRoot,
);
제가 구현한 모달의 구조입니다. 하나씩 컴포넌트별로 살펴보겠습니다.
'use client';
import { useState } from 'react';
import PlusIcon from '@/public/plus.svg';
import AddChatRoomModal from '@/components/ui/Modal/AddChatRoomModal';
export default function AddChatRoomButton() {
const [isOpen, setIsOpen] = useState(false);
const handleModal = () =>{
setIsOpen(!isOpen)
}
return (
<>
<AddChatRoomModal open={isOpen} onClose={() => setIsOpen(false)} />
<button onClick={handleModal}>모달 트리거 버튼</button>
{...기타코드}
</>
);
}
제가 구현한 모달은 모달을 트리거하는 곳에서 렌더링여부를 관리하는 상태, 모달을 닫는 함수를 props로 내려받도록 구현하였습니다. 또한
기본적으로 모달을 트리거하기 위해서는 보통 특정 버튼을 클릭하게 됩니다. 따라서 특정 모달을 트리거하는 버튼을 따로 컴포넌트 화하여 관리하였습니다.
function Overlay({
children,
onClose,
}: {
children: ReactNode;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center w-full h-screen bg-black bg-opacity-70"
onClick={onClose}
>
{children}
</div>
)
모달을 트리거했을 때 뒷배경을 어둡게 해 주는 역할을 하는 컴포넌트입니다. onClose는 모달을 트리거하는 부분에서 props로 전달을 받습니다.
FocusLock 이란?
https://github.com/theKashey/react-focus-lock#readme
FocusLock 이란 키보드이벤트로 인한 포커스를 관리하기 위한 라이브러리로 모달구현에 있어 유용한 라이브러리입니다.
해당라이브러리를 사용한다면 만약 유저가 Tab키로 우리의 웹페이지를 순환한다고 하였을 때 현재 열린 모달에 포커스가 고정됩니다.
function ModalWrapper({ children }: { children: ReactNode }) {
return <div onClick={(event) => event.stopPropagation()}>{children}</div>;
}
모달콘텐츠를 감싸는 ModalWrapper 컴포넌트입니다. 해당 컴포넌트 상위에 Overlay 컴포넌트가 존재하고 Overlay 컴포넌트에는 onClose 즉 모달을 닫는 이벤트가 걸려있습니다. 따라서 모달컨텐츠를 클릭하면 이벤트가 전파되어 모달이 닫히게 되는데요.
이를 방지하기 위해 이벤트가 상위요소로 전파되는 것을 막는 stopPropagation을 사용해 주었습니다.
이중모달시 overflow 버그 수정해 보기.
기존 모달도 지금 모달과 마찬가지로 트리거하는 부분에서 모달렌더링여부를 관리하는 상태를 prop으로 내려받아 렌더링여부를 결정해 주었습니다.
위 방법은 모달이 한 개일 땐 문제가 없지만 이중모달일 때 발생하는데요. 만약 모달 안에서 모달을 한 번 더 클릭하게 된다면 모달을 관리하는 상태가 다시 한번 바뀌기 때문에 첫 모달 렌더링시 overflow hidden이었던 속성이 다시 auto로 바뀌게 되는 문제가 있었습니다.
const [modalRoot, setModalRoot] = useState<Element | null>(null);
useEffect(() => {
setModalRoot(document.getElementById('modal-root'));
}, []);
//Esc를 눌렀을떄 모달을 닫기 위한 함수
const handleModalVisible = (event: KeyboardEvent) => {
if (!open || event.key !== 'Escape') return;
onClose();
};
useEffect(() => {
// modalRoot 하위에 요소가 없다면 사이드이펙트 종료.
if (!modalRoot?.hasChildNodes()) return;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleModalVisible);
return () => {
document.body.style.overflow = 'auto';
document.removeEventListener('keydown', handleModalVisible);
};
}, [open]);
if (!modalRoot) {
return null;
}
완성된 모달
모달에 구현에 필요한 모든 코드입니다.
'use client';
import { ReactNode, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import FocusLock from 'react-focus-lock';
interface ModalProps {
children: ReactNode;
open: boolean;
onClose: () => void;
}
function Overlay({
children,
onClose,
}: {
children: ReactNode;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center w-full h-screen bg-black bg-opacity-70"
onClick={onClose}
>
{children}
</div>
);
}
function ModalWrapper({ children }: { children: ReactNode }) {
return <div onClick={(event) => event.stopPropagation()}>{children}</div>;
}
export default function Modal({ children, open, onClose }: ModalProps) {
const [modalRoot, setModalRoot] = useState<Element | null>(null);
useEffect(() => {
setModalRoot(document.getElementById('modal-root'));
}, []);
const handleModalVisible = (event: KeyboardEvent) => {
if (!open || event.key !== 'Escape') return;
onClose();
};
useEffect(() => {
if (!modalRoot?.hasChildNodes()) return;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleModalVisible);
return () => {
document.body.style.overflow = 'auto';
document.removeEventListener('keydown', handleModalVisible);
};
}, [open]);
if (!modalRoot) {
return null;
}
return createPortal(
<>
{open && (
<Overlay onClose={onClose}>
<FocusLock>
<ModalWrapper>{children}</ModalWrapper>
</FocusLock>
</Overlay>
)}
</>,
modalRoot,
);
}
모달을 사용해 보자
모달을 사용하고 있는 컴포넌트 내부입니다. 모달은 렌더링 하고자 하는 UI에 전혀 관여하지 않고 그저 화면 가운데 위치하며 Overlay를 같이 렌더링 하는 역할만 담당합니다.
따라서 반응형도 내부콘텐츠에 따라 바뀌기 때문에 반응형 되어야 할 모달, 반응형이 필요 없는 모달을 구현함에 있어 한결 자유로워졌습니다.
'use client';
export default function AddChatRoomModal({ open, onClose }: ModalProps) {
// ..기타코드
return (
<Modal open={open} onClose={onClose}>
<form
className="flex flex-col w-screen tablet:w-[800px] h-screen tablet:h-[720px]"
>
{...기타코드}
</form>
</Modal>
);
}
'React' 카테고리의 다른 글
널리 쓰이는 리액트 공통 컴포넌트 설계 해보기 (0) | 2024.06.06 |
---|---|
React Developer Tools를 통한 웹 최적화 진행 해보기 (0) | 2023.11.08 |
React-Query - onClick 이벤트로 useQuery data fetching (0) | 2023.10.09 |
React- 재사용성과 확장성을 높이는 리액트 컴포넌트 설계 하기 (1) | 2023.09.25 |
React - 리액트 해시태그 구현 (0) | 2023.09.23 |