상품을 클릭하면 모달 창으로 해당 상품의 상세 페이지가 뜨는 기능을 구현 중이다. 리뷰어 분의 피드백 중에 React Portals를 활용해보라는 내용이 있어서 Portals에 대해 알아보고 바꿔보려고 한다.
우리 팀이 Modal을 구현하기 전에, 먼저 구현해 본 펭도리가 우리 팀을 찾아와서 질문했다. 펭돌 팀이 짠 구조가 완전하게 기억은 안 나는데 대략 어떤 것이었냐면, 상품을 클릭하면 그 상품에 대한 상세 페이지가 떠야 하기 때문에 모달 창을 상품리스트(우리 팀의 컴포넌트 트리에서는 CarouselSectionList
나 CarouselSection
이 될 듯)의 자식으로 렌더링되는 구조였다.
캐로셀을 구현하기 위해 상품 아이템을 담고 있는 ul
에 transform
이 걸려있는 상태였고, 모달 창의 배경 색을 어둡게 하기 위해 모달 창 배경에 해당하는 div
에 position: fixed
를 주니, 그 배경이 화면 전체를 덮는 게 아니라 ul
범위에 갇히는 문제가 있었다.
검색해보니 MDN에 다음과 같은 내용이 나와있다.
position: fixed
요소를 일반적인 문서 흐름에서 제거하고, 페이지 레이아웃에 공간도 배정하지 않습니다. 대신 뷰포트의 초기 컨테이닝 블록을 기준으로 삼아 배치합니다. 단, 요소의 조상 중 하나가 transform, perspective, filter 속성 중 어느 하나라도 none이 아니라면 (CSS Transforms 명세 참조) 뷰포트 대신 그 조상을 컨테이닝 블록으로 삼습니다. (perspective와 filter의 경우 브라우저별로 결과가 다름에 유의) 최종 위치는 top, right, bottom, left 값이 지정합니다.
즉, 모달의 조상 중 하나인 ul
을 움직이기 위해 transform
을 걸어놓은 것이 모달창이 ul
에 갇힌 원인이었다. 그래서 모달을 캐로셀 ul
의 자식으로 넣으면 안 되겠다는 결론을 내렸고 MainPage
컴포넌트의 자식으로 넣게 되었다.
카드들과 모달이 있는 부분의 컴포넌트 트리를 다시 한 번 보자.
카드를 클릭했을 때 그 카드의 정보를 어떻게 모달에게 전달해줄 수 있을까?
이 과정은 모달과 카드의 공통 조상인 MainPage
에서 처리해주었다.
모달이 카드의 자식으로 들어있다면 그대로 prop을 받아 화면에 보여주면 되지만, 위와 같은 구조에서는 그게 불가능하기 때문에 상세 페이지에 대한 데이터를 미리 받아놓고, 클릭한 카드의 정보와 미리 받아놓은 데이터를 비교해서 일치하는 것을 화면에 보여주도록 구현했다.
API에 상세페이지마다 고유한 hash가 있기 때문에 시간복잡도를 낮추기 위해서 filter
대신 Map
을 사용했다.
// MainPage 컴포넌트 내부
const [modalState, setModalState] = useState(false);
const [modalData, setModalData] = useState({});
const [detailDataMap, setDetailDataMap] = useState(new Map());
useEffect(() => {
fetch(
'요청보내는 url...'
)
.then((res) => res.json())
.then((response) => {
response.body.forEach((e) => {
setDetailDataMap(detailDataMap.set(e.hash, e.data)); // 상세페이지 하나하나(e)를 Map에 넣어준다.
});
});
}, []);
const handleModal = (product) => {
setModalState(true);
const detailData = detailDataMap.get(product.detail_hash);
setModalData({ ...product, ...detailData });
};
이렇게 MainPage
단에서 handleModal
을 정의해주고 TabSection - Card
, CarouselSectionList - Card
각각의 카드로 넘겨줬다.
리액트 왕왕초보인 나는 위 구조도 나쁘지 않다고 생각하고 있었고 사실 저거 말고 또 어떻게 다르게 짤 수 있는지 아예 몰랐는데, 리뷰어님이 알려주신 Portals.. 너무 좋아보인다.
index.html
의 root
아래에 modal
을 추가해준다.
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<div id="global-modal"></div> // 👈 추가!
</body>
const Modal = ({ children }) => {
const modal = document.querySelector("#global-modal");
return ReactDOM.createPortal(children, modal);
};
export default Modal;
포탈을 index.html
의 root
아래에 다른 div(#global-modal
)에 열어줬기 때문에, 코드 상으로는 Modal
이하가 Card 컴포넌트의 리턴값으로 쓰여 있지만 실제로는 #global-modal
안에 렌더링된다.
// Card 컴포넌트에서 리턴하는 값
// Modal UI는 ModalCard 라는 이름의 컴포넌트
return (
<>
<StyledLi
cardSize={cardSize}
margin={margin}
onClick={requestProductDetailInfo}
>
<Thumbnail {...{ product, cardSize, type }} />
<StyledTitle>{product.title}</StyledTitle>
<StyledDescription>{product.description}</StyledDescription>
<Price product={product} />
<LabelList>
{product.badge && product.badge.map((e) => <Label badgeName={e} />)}
</LabelList>
</StyledLi>
<Modal> // 👉 Portal
{modalState && (
<ModalBackground> // 👉 모달의 배경을 어둡게 해주는 역할
<ModalContainer> // 👉 UI 배치를 위한 컨테이너
<ModalCard product={{ ...product, ...detail }} /> // 👉 모달UI
<IconButton // 👉 모달 창 닫는 X 버튼
type="CLOSE"
fn={() => setModalState(false)}
margin={10}
/>
</ModalContainer>
</ModalBackground>
)}
</Modal>
</>
);
Portal을 이용하면 어떤 컴포넌트가 특정 컴포넌트의 자식으로 종속되지 않고 우리가 원하는 위치에 렌더링시킬 수 있다.
즉, Modal을 렌더링할 때 Card 컴포넌트의 return 값으로 넣어주어 화면을 표시하는 데에 필요한 데이터를 props로 쉽게 받으면서 최상단인 root와 형제로 렌더링 시킬 수 있다.
이렇게 되면 Modal이 최상단에 렌더링되기 때문에 z-index
를 사용하지 않고도 손쉽게 모달을 맨 위에 위치시킬 수 있다.
그런데 크롱의 피드백이 그닥 좋지 않다... 🤔
이번 프로젝트에서 사용된 모달은 예외적인 경우가 아니라서 그런 걸까..? 이유는 아직 잘 모르겠다.
깔끔한 정리 잘 봤어요 오통~ 특히, fixed 자세히 알아갑니당