React Hooks | Portals

Autumn·2021년 4월 26일
1

React

목록 보기
1/1
post-thumbnail

상품을 클릭하면 모달 창으로 해당 상품의 상세 페이지가 뜨는 기능을 구현 중이다. 리뷰어 분의 피드백 중에 React Portals를 활용해보라는 내용이 있어서 Portals에 대해 알아보고 바꿔보려고 한다.


현재 컴포넌트 구조

왜 Modal을 카드의 자식으로 넣지 않았나?

우리 팀이 Modal을 구현하기 전에, 먼저 구현해 본 펭도리가 우리 팀을 찾아와서 질문했다. 펭돌 팀이 짠 구조가 완전하게 기억은 안 나는데 대략 어떤 것이었냐면, 상품을 클릭하면 그 상품에 대한 상세 페이지가 떠야 하기 때문에 모달 창을 상품리스트(우리 팀의 컴포넌트 트리에서는 CarouselSectionListCarouselSection이 될 듯)의 자식으로 렌더링되는 구조였다.
캐로셀을 구현하기 위해 상품 아이템을 담고 있는 ultransform이 걸려있는 상태였고, 모달 창의 배경 색을 어둡게 하기 위해 모달 창 배경에 해당하는 divposition: 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.. 너무 좋아보인다.

1. index.html에 div 추가

index.htmlroot 아래에 modal을 추가해준다.

<body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <div id="global-modal"></div> // 👈 추가!
  </body>

2. Portal 생성 (Modal 컴포넌트)

const Modal = ({ children }) => {
  const modal = document.querySelector("#global-modal");
  return ReactDOM.createPortal(children, modal);
};

export default Modal;

3. Card 컴포넌트의 자식 컴포넌트로 Modal(포탈)을 렌더링

포탈을 index.htmlroot 아래에 다른 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를 사용하지 않고도 손쉽게 모달을 맨 위에 위치시킬 수 있다.

  • 그런데 크롱의 피드백이 그닥 좋지 않다... 🤔

    이번 프로젝트에서 사용된 모달은 예외적인 경우가 아니라서 그런 걸까..? 이유는 아직 잘 모르겠다.

profile
한 발짝씩 나아가는 중 〰 🍁 / 자잘한 기록은 아래 🏠 아이콘에 연결된 노션 페이지에 남기고 있어요 😎

3개의 댓글

comment-user-thumbnail
2021년 4월 29일

깔끔한 정리 잘 봤어요 오통~ 특히, fixed 자세히 알아갑니당

1개의 답글
comment-user-thumbnail
2021년 5월 2일

포탈이란것도 있었군용 저도 몰랐네요~

답글 달기