[React] Portal (포탈), Modal 구현하기
회사에서 선배와 React로 모달 구현에 대해 고민하다가 알게된 내용이었다.
우리 회사 프로젝트는 Vue를 사용하기 때문에, 플러그인(this.$modal) 메서드를 통해 루트에 모달 컴포넌트를 주입, 삭제해서 표현한다.
하지만 React의 경우엔 어떻게 구현되는지 몰랐으며, 전역모달이 구현되는 영역에 Context API를 적용하면 되나 이 영역들이 리렌더링된다는 단점이 있었다.
그렇기에 전역상태를 통해 모달을 on/off 하는 방법을 찾아봤으며, 이 때 React의 Portal 기능을 통해 좀 더 효율적인 구현이 가능했다.
💙 Portal 이란?
React 공식문서에 따르면, Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법이라고 소개하고 있다. (React v16 출시)
통상은, React의 index.html의 #root <div> 외에, 다른 루트에 Modal, Dialog, Tooltip 등을 띄우기 위해 많이 사용되나,
반드시 제2의 루트가 아니라 단순히 부모 컴포넌트 DOM 외부의 DOM노드에 렌더링하기 위한 목적으로도 사용될 수 있다.
- Portal을 사용하는 이유
전역모달을 띄우는 범용적인 방법은, <App> 바로 하위에 <Modal> 컴포넌트를 만든 뒤 전역상태(Boolean)을 통해 show/hide 하는 것이다.
이 역시도 상위단에 DOM노드를 생성했을뿐만 아니라, 단순한 알람모달의 경우에도 전역상태에 의존한다는 불합리함이 존재한다.
또한, 모달이 켜진 상태에서 업데이트 등으로 부모 엘리먼트가 리렌더된다면, tree구조에 따라 자식인 모달 역시 영향을 받게 될 것이다.
그리고, 부모 컴포넌트의 스타일링에 제약을 받아 z-index 와 같은 후처리를 해야한다는 점도 있다.
이러한 DOM Tree 상에서의 부모-자식 간의 제약에서 자유로워지기 위해 Portal 기능을 사용하게 되는 것이다.
또한, Portal에 렌더링된 자식요소는 이벤트 버블링이 반영되기 때문에 부모요소와의 통신 측면에서도 유용하다.
💙 Portal로 Modal 구현하기
기본적으로, ReactDOM.createPortal() 메서드를 통해 Portal 기능을 사용할 수 있다.
이 Portal이 가장 많이 사용되는 Modal 구현방법에 대해, 실습해본 내 코드를 기반으로 사용법을 익히는 것이 일타쌍피라고 생각한다!
1. Portal 렌더 노드 심어주기
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<!-- ... -->
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
</html>
최상단 요소인 #root와 형제관계인 #modal-root 요소를 만들어준다. 여기에, Portal을 통해 모달 컴포넌트가 렌더링될 것이다.
2. ModalPortal.tsx 만들기
// ModalPortal.tsx
import React from 'react';
import ReactDOM from 'react-dom';
const ModalPortal: React.FC = ({ children }) => {
const modalRoot = document.getElementById('modal-root');
return ReactDOM.createPortal(children, modalRoot);
};
export default ModalPortal;
Portal을 통해 #modal-root에 child 컴포넌트를 렌더링해주는 Container 컴포넌트를 만들어준다.
ReactDOM.createPortal() 메서드로 렌더링이 가능하며, 인자는 렌더링할 컴포넌트, 타겟노드 2개를 넣어준다.
3. ModalFrame.tsx 만들기
// ModalFrame.tsx
import React from 'react';
import ModalPortal from './ModalPortal';
import styles from './modal.module.less';
type Props = {
children: string;
setOnModal: (state: boolean) => void;
};
const ModalFrame: React.FC<Props> = ({ children, setOnModal }: Props) => {
return (
<ModalPortal>
<div className={styles.modalDim} onClick={() => setOnModal(false)}>
<div className={styles.modalBox}>
{children}
<button className="close" onClick={() => setOnModal(false)}>
X
</button>
</div>
</div>
</ModalPortal>
);
};
export default ModalFrame;
다음으로, Modal에 공통으로 사용될 레이아웃 Container 컴포넌트를 만들어주었다.
<ModalPortal>로 랩핑하여 내부요소를 포탈에 띄워준다. 이제 <ModalFrame> 으로 감싼 내부요소를 모달창으로 띄울 수 있다.
// TestModal.tsx
import React from 'react';
import ModalFrame from './ModalFrame';
type Props = {
setOnModal: (state: boolean) => void;
};
const TestModal: React.FC<Props> = ({ setOnModal }) => {
return <ModalFrame setOnModal={setOnModal}>테스트 모달!</ModalFrame>;
};
export default TestModal;
또, setOnModal 세터함수를 Props로 받아왔는데, dim영역 및 X버튼의 클릭 이벤트가 버블링되기 때문이다.
이를 통해, 모달을 사용한 부모 컴포넌트로 모달 표현여부(Boolean)를 false로 갱신하여 모달을 끄는 동작까지 구현할 수 있다.
4. Modal 조건부 렌더링
import React, { useState } from 'react';
import PrimaryButton from '@/views/components/common/button/PrimaryButton';
import TestModal from '@/views/components/common/modal/TestModal';
import styles from './Example.module.less';
const Example = () => {
const [onModal, setOnModal] = useState(false);
return (
<div className={styles.example}>
<PrimaryButton theme="green" onClick={() => setOnModal(true)}>모달</PrimaryButton>
{onModal && <TestModal setOnModal={(bool) => setOnModal(bool)} />}
</div>
);
};
export default Example;
먼저, 모달 표현여부를 onModal 상태값(Boolean)으로 토글링한다. 여기에 따라, <TestModal>이 조건부 렌더링된다.
특히, TestModal > ModalFrame > ModalPortal 을 통해 setOnModal() 메서드를 Props로 넘겨줬으며,
클릭 이벤트가 버블링되며 onModal을 false로 바꾸며 모달을 끄는 동작까지 구현할 수 있었다.
모달이 구현된 모습이다! 위 코드는 특정 컴포넌트에서 모달 렌더링 여부(onModal)이 한정적으로 조회 및 제어된다.
로그인 모달과 같은 전역모달은 Redux나 Recoil과 같은 전역상태를 통해 렌더링해야 할것이며, 이러한 제어는 useModal() Hooks API로 유용하게 구현할 수 있다. (참고링크)
💙 Portal과 Render 의 차이점
Render는 ReactDOM.render(element, container) 문법으로 사용하며, Portal과 매우 유사하다.
하지만 가장 큰 차이점은 Render는 새로운 React Lifecycle을 생성하기 때문에, React App이나 독자적인 컴포넌트 렌더링에 사용된다. (ex) Notification)
구분 | Portal | Render |
공통점 | 특정 DOM으로 컴포넌트를 렌더링 | |
호출 위치 | React Render 내부* | 어디서든 가능 |
LifeCycle | 호출한 부모 컴포넌트에 종속됨 | 새로운 생명주기로 관리됨 |
Unmount | Lifecycle에 의해 언마운트됨 | 직접 ReactDOM.unmountComponentAtNode() 사용 |
event | 실제 렌더링 위치는 다르나, 부모요소와 버블링/캡처링 가능 | 최상위 컴포넌트로 동작 |
활용 예시 | 특정 컴포넌트를 부모요소 외부에 렌더링하고자 할 때 | React App을 새로 생성할 때 |
Portal에 대한 간단한 포스팅이 끝났다.
Portal의 개념에 대해서도 공부했지만, 무엇보다 이전과는 다른 방법으로 React에서 모달을 구현해본 것이 큰 경험치가 되었다!
위에서 언급했듯이, 전역모달을 만들기 위한 Recoil의 접목과 useModal() Hooks API까지 이 글에 첨부하거나 새로이 포스팅해봐야겠다.
📎 출처
- [Portals] React 공식문서 : https://ko.reactjs.org/docs/portals.html.
- [Portal로 Modal 구현] bomdong 님의 블로그 : https://dev-bomdong.tistory.com/m/21
- [Portal로 전역모달 구현] 마이구미 님의 블로그 : https://mygumi.tistory.com/406
- [Portal과 Render] 김재서 님의 블로그 : https://jaeseokim.dev/React/React-Portal_Render%EC%9D%98_%EC%B0%A8%EC%9D%B4%EC%A0%90_%ED%99%9C%EC%9A%A9%EB%B0%A9%EC%95%88_%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0/