react

React.createPortal을 이용해 모달 만들기

냠냠맨 2023. 5. 15. 22:50

😍createPortal은..?

https://react.dev/reference/react-dom/createPortal

 

createPortal – React

The library for web and native user interfaces

react.dev

공식문서를 참고할 수 있습니다.

createPortal은 일부 자식들을 DOM의 다른 부분으로 렌더링할 수 있는 기능을 제공합니다.

편의상 vite, cra 등 보일러플레이트를 사용해 리액트 프로젝트를 구성했다고 가정하고 진행하겠습니다.

 

리액트로 구성한 프로젝트는 기본적으로 보통  index.jsx 혹은 main.jsx 와 같은 엔트리포인트에서

위와 같이 렌더를 시켜줍니다.

import ReactDOM from 'react-dom/client';
import App from './App.tsx';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
    <App />
);

자세히 보면 코드의 의미를 쉽게 알아챌 수 있습니다.

as HTMLElement는 TypeScript의 문법으로 신경쓰시지 않으셔도 괜찮습니다.

이것은 확실히 HTMLElement라고 알려주는 코드라고 생각해주시면 되겠습니다.

 

ReactDOM 라이브러리가 제공하는 reateRoot함수가 반환하는 render 함수를 이용하는데

createRoot 함수는 인자로 먼저 HTMLElement를 받습니다.

이 Element는 Id가 root인 HTML 요소를 의미합니다.

id가 root인 HTML 요소는 어디에 있을까요?

  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>

프로젝트를 잘 살펴보면 index.html과 같이 미리 정의되어있는 html 파일을 찾을 수 있습니다.

이 html 파일이 개발서버를 구동하고 나면 화면에 보이게되는 html 파일이라고 생각할 수 있습니다.

 

ReactDOM 라이브러리가 제공하는 함수이며 사용방법은 다음과 같습니다.

createPortal(children, domNode, key?)

첫번째 인자로는 domNode의 children이 될 컴포넌트 혹은 jsx 문법으로 작성한 html 태그가 들어갑니다.

리액트로 렌더링할 수 있는 모든것이 들어갈 수 있다라고 생각해주시면 되겠습니다.

 

두번째 인자로는 children의 부모가 될 domNode를 셀렉해줍니다.

이것은 미리 생성되어있는 domNode를 셀렉해주어야합니다.

아까 위에서 index.html을 살펴보았던것이 기억나신다면 쉽게 예상할 수 있습니다.

그 index.html에 portal을 태우게할 html 요소를 미리 만들어놓으면 되겠네요!

 

세번째는 선택인자입니다.

포털의 키로 사용할 고유한 문자열 혹은 숫자를 넣어주면 됩니다.

import { createPortal } from 'react-dom';

// ...

<div>
  <p>This child is placed in the parent div.</p>
  {createPortal(
    <p>This child is placed in the document body.</p>,
    document.body
  )}
</div>

예제를 보겠습니다.

포털을 만들려면 react-dom에서 createPortal을 import해주는 것으로 시작합니다.

그리고 첫번째에는 html 태그를 두번째에는 부모요소가 될 html 요소를 셀렉팅해줍니다.

즉 위 코드는 body 태그에 p태그를 자식요소로 달아주는 코드라고 볼 수 있습니다.

 


🤡모달 만들어보기

https://codesandbox.io/s/tsdml4?file=%2FApp.js&utm_medium=sandpack 

 

summer-bash-tsdml4 - CodeSandbox

summer-bash-tsdml4 using react, react-dom, react-scripts

codesandbox.io

 

리액트 공식문서에서 제공하는 모달에 대한 예제도 존재합니다!

솔직히 저거만 참고하시면서 만들어도 충분하시겠지만

css 측면에서 집중하면서 한번 만들어보겠습니다.

한 컴포넌트에서 모든걸 처리해도 좋지만 역할을 조금 나누어 주겠습니다.

 

모달창이 띄워졌을 때 뒷배경을 흐리게 만들어주는 backDrop효과를 위한

backDrop 컴포넌트와 실제 Modal의 내용이 들어갈 ModalImage 컴포넌트

그리고 그 둘을 합쳐서 하나의 컴포넌트로 만들 Modal 컴포넌트를 만들어주겠습니다.

 

index.html

  <body class="darkmode lightmode">
    <div id="modal"></div>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>

index.html에 id가 modal인 div 태그를 만들어주었습니다.

 

ModalImage.tsx

import BookMarkStar from '../../icons/BookMarkStar';
import { starActiveColor, starNonActiveColor } from '../../colors/colors';
import Delete from '../../icons/Delete';
import ModalProps from '../../types/ModalProps';
import useBookmark from '../../hooks/useBookmark';
import React from 'react';

const ModalImageWidth = '46.5rem';
const ModalImageHeight = '30rem';

const ModalImage = ({ data, src, title, setIsOpen }: ModalProps) => {
  const bookMarkHandler = useBookmark();

  return (
    <figure className="fixed bottom-0 left-0 right-0 top-0 z-20 flex items-center justify-center">
      <div className={`relative h-[30rem] w-[46.5rem]`}>
        <button
          onClick={(event: React.MouseEvent) => {
            setIsOpen((state) => !state);
          }}
          className=" absolute right-[2.875rem] top-[3.125rem] cursor-pointer"
        >
          <Delete />
        </button>
        <img
          className={` rounded-3xl h-[${ModalImageHeight}] w-[${ModalImageWidth}]`}
          src={src}
          width={ModalImageWidth}
          height={ModalImageHeight}
        />
        <div className="absolute bottom-[3.125rem] left-[3.375rem]">
          <div className=" flex">
            <button onClick={() => bookMarkHandler(data)}>
              <BookMarkStar
                className=" cursor-pointer"
                fill={data.bookmark ? starActiveColor : starNonActiveColor}
              />
            </button>
            <span className=" px-2 text-xl font-bold text-white">{title}</span>
          </div>
        </div>
      </div>
    </figure>
  );
};

export default ModalImage;
export { ModalImageWidth, ModalImageHeight };

대부분의 로직은 무시하셔도 좋습니다.

다만 중요한 부분은 figure 태그의 css입니다.

    <figure className="fixed bottom-0 left-0 right-0 top-0 z-20 flex items-center justify-center">

position:fixed 이후 top, bottom, left, right의 위치를 모두 0으로 주어 초기화시켜줍니다.

그렇게 해둔 뒤 디스플레이는 flex를 주겠습니다.

display: flex

align item : center

justify content : center

를 통해 가운데에 배치해줍니다.

이렇게 코드를 작성하면 어떤 환경에서든 뷰포트의 항상 가운데에 위치하게됩니다.

z-index의 경우에는 backdrop보다 앞에 위치시켜주기 위함입니다.

 

BackDrop.tsx

const BackDrop = () => {
  return (
    <div className=" fixed left-0 top-0 z-20 h-full w-full bg-slate-200 bg-opacity-40"></div>
  );
};

export default BackDrop;

다음은 BackDrop의 css입니다.

h-full, w-full로 가로세로를 모두 채워주게 코드를 작성합니다.

그리고 opacity를 낮춰주는 코드입니다.

이 역시 fixed와 left, top 0으로 위치를 고정시켜 주는것이 중요합니다.

 

Modal.tsx

import BackDrop from './BackDrop';
import { createPortal } from 'react-dom';
import ModalImage from './ModalImage';
import ModalProps from '../../types/ModalProps';

const portalElement = document.getElementById('modal') as HTMLElement;

const Modal = ({ setIsOpen, src, data, title }: ModalProps) => {
  return (
    <>
      {createPortal(
        <>
          <BackDrop />
          <ModalImage
            setIsOpen={setIsOpen}
            src={src}
            data={data}
            title={title}
          />
        </>,
        portalElement,
      )}
    </>
  );
};

export default Modal;

다음은 Modal.tsx입니다.

createPortal을 통해 우리가 만든 backdrop과 modalimage를 취합해서 렌더시킵니다.

그리고 아까 만들어둔 modal이 아이디인 element를 셀렉해서 

빈 꺽쇠태그로 감싸주는것은 Fragment라고 생각해주시면 되겠습니다.


🙀모달 닫고 끄는 로직

useState를 이용해 작성하면 됩니다.

조건부 렌더를 이용하여 useState가 true일때에는 모달을 보여주고

state가 false일때는 모달을 안보여주는 식으로 하는거죠!

그리고 setState를 모달에게 전달해주면 되겠습니다.


🤗마치며

항상 이런 모달, 드롭다운과 같은 기능들은 css를 구현하는것이 귀찮은 것 같습니다.

핵심적인 부분은 기억해두거나 포스팅해두고 참고하시면서 작성하시면

생산성이 높아지시지 않을까 합니다.

다음은 완성된 모달입니다.

반응형