createPortal에 대해

Sheryl Yun·2024년 2월 15일
1

React.js

목록 보기
24/24
post-thumbnail

사이드 프로젝트의 헤더에 구현해둔 언어 변경 모달을 이를 createPortal을 사용한 모달로 바꾸려고 시도했었다. 하지만 결과적으로는 전체 화면 사용이 필요 없는 경우였고(hover하면 모달이 뜨고 커서가 나가면 사라지는 형태), position: absolute를 사용한 모달의 css 때문인지 모달을 보여지게 하는 데 애를 먹어서 적용을 하지는 않았다. (이제 와서 생각해보니 모달이 아니라 드롭다운이었을지도)

하지만 이 과정에서 createPortal에 대해 조사하게 되면서 정말 좋은 기능이라는 생각이 들어 포스팅하게 되었다.

createPortal이란?

createPortal은 리액트에서 제공하는 '부모 컴포넌트의 DOM 계층 구조 밖에서 자식을 렌더링하는 최고의 방법'이다.

리액트의 모든 컴포넌트를 렌더링하는 id='root' div의 형제인 척 하면서 실제로는 root div의 자식 컴포넌트로 기능한다. 렌더링만 root 형제 위치, 즉 root 밖에서 이루어진다.

createPortal을 사용하는 이유

  • 리액트는 기본적으로 부모가 리렌더링되면 자식도 리렌더링된다. 이 과정에서 전체 화면을 사용하는 모달이 '특정 부모의 자식'으로 있게 되면 불필요한 리렌더링의 위험을 안게 된다.
  • 특정 컴포넌트 밑에 있음으로써 발생하는 css 제약을 극복할 수 있다. 실제로 조사했던 블로그 중에 createPortal을 쓰기 전에는 (보통 최상단에서 모달 컴포넌트를 넣어주는) App.tsx 파일의 App.css의 영향을 받았지만 createPortal로 변경한 후 그러한 문제가 사라졌다고 한다. (참고: 박히밍 개발 블로그)
  • 또 다른 이유는 '좋은 구조'의 문제이다. 의미적으로 '모든 컴포넌트의 상위에서 렌더링되어야 할' 모달 컴포넌트가 어떤 특정 컴포넌트 아래에 있다는 것 자체가 의미적으로 좋지 못한 구조라고 한다. css 스타일링을 기능적으로는 작동시킬 수 있을지 몰라도 마치 ul/li를 써야 할 요소에 모두 div를 써버린 것처럼 의미적으로 좋지 않다는 뜻이다.

이러한 리렌더링 이슈, css 제약, 의미적 부조화 문제를 편하게 극복할 수 있게 하는 방법이 바로 createPortal이다. createPortal은 모달 컴포넌트가 실제로는 리액트 DOM 내에서 동작하면서 DOM 상에서는 리액트 DOM 내에 '중첩'되지 않도록 해 준다.

createPortal 써 보기

1. id='root'에 형제 DOM 만들기

형제처럼 보여도 실제로는 root의 자식처럼 동작하고 실제로는 root 바깥에서 렌더링한다.

// public/index.html

  <body>
    <div id="root"></div>
    <div id="modal"></div> // 추가
  </body>

2. Portal 컴포넌트 만들기 (모듈화, 선택적)

이 과정은 생략 가능해서 ModalPortal 컴포넌트 없이 바로 portal을 사용할 곳에서 createPortal 메서드로 모달 컴포넌트를 감싸도 된다.

하지만 JSX 형태를 최대한 단순하게 유지하기 위해 개인적으로는 이렇게 분리하는 게 더 깔끔한 듯 하다.

//Portal.js
import reactDom from "react-dom";

const ModalPortal = ({ children }) => {
  const el = document.getElementById("modal");
  return reactDom.createPortal(children, el);
};

export default ModalPortal;

모달 컴포넌트 만들기

onClose 함수를 prop으로 받는(모달에는 '닫는' 기능만 있으면 된다) 렌더링할 모달 컴포넌트를 만든다.

//Modal.js
import React from "react";
import styled from "styled-components";

const Modal = ({ onClose }) => {

  return (
      <Background>
        <Content>
          // ...
        </ Content>
      </Background>
  );
};

export default Modal;

// 모달을 한가운데 위치시키는 Background 스타일
const Background = styled.div`
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  position: fixed;
  left: 0;
  top: 0;
  text-align: center;
`;

const Content = styled.div`
  height: 100%;
  width: 950px;
  margin-top: 70px;
  position: relative;
  overflow: scroll;
  background: #141414;
`;

모달 등장 여부를 조작하는 컴포넌트에 추가

모달을 띄울지 여부는 보통 state를 사용해 조작한다. state를 사용할 스코프를 벗어나지 않도록 여기에 Portal과 모달 컴포넌트를 넣어준다. (state를 활용하여 모달을 조건부 렌더링)

//modal을 띄우려는 컴포넌트 파일
import styled from "styled-components";
import ModalPortal from "../Components/Modal/Portal";
import Modal from "./Modal/Modal";

const Carousel = props => {
  const [modalOn, setModalOn] = useState(false);

  const handleModal = () => {
    setModalOn((modalOn) => !modalOn);
  };
  
  return (
    <>
      <Container>
    	<button onClick={handleModal}/>
        
        // Portal과 모달 조건부 렌더링
        <ModalPortal>
          {modalOn && <Modal onClose={handleModal} />}
        </ModalPortal>
      </Container>
    </>
  );
};

export default Carousel;

추가: 모달 뒤편의 스크롤 막기

화면 전체에 모달이 떴을 때 뒤의 배경이 스크롤 되는 경우가 있을 수 있는데 이를 막는 방법이다. (참고: 단민님 블로그)

방법: 최상단 html 요소에 css 한 줄을 넣었다 뺐다 하면 된다.
(다른 블로그에서 가져온 코드이므로 위와 코드가 다르다 - 유의)

const BuyMeACoffee: React.FC = () => {
  const [isModalOpened, setIsModalOpened] = useState(false);

  const html = document.querySelector('html'); // 여기

  const openModal = () => {
    setIsModalOpened(true);
    html?.classList.add('scroll-locked'); // 여기
  };

  const closeModal = () => {
    setIsModalOpened(false);
    html?.classList.remove('scroll-locked'); // 여기
  };

  return (
    <>
      <S.Button onClick={openModal}>
        <S.Text>
          {'BuyMeACoffee'.split('').map((char, index) => (
            <p key={index}>{char}</p>
          ))}
        </S.Text>
      </S.Button>

      {isModalOpened &&
        createPortal(
          <S.ModalBackground onClick={closeModal}>
            <S.Modal>
              <S.Title>Buy Me A Coffee ☕️</S.Title>
            </S.Modal>
          </S.ModalBackground>,
          document.body,
        )}
    </>
  );
};

참고 자료

profile
데이터 분석가 준비 중입니다 (티스토리에 기록: https://cherylog.tistory.com/)

0개의 댓글