[React] Portal을 사용한 모달창 만들기
Front-End/개발일지

[React] Portal을 사용한 모달창 만들기

 

 

 

 

 

 

 

 

모달이란, 다이얼로그 실행 시 포커스와 제어권을 독점하여 다이얼로그를 종료하기 전까지 기존의 화면을 제어할 수 없는 기능을 뜻한다.

따라서 모달은 항상 화면의 최상위에 위치해야 하며, 모달이 열려있을 때는 기존 화면의 제어가 불가능해야 한다.

 

 

 

 

 

 

 

일반적인 방법으로 모달을 띄우는 방법은 다음과 같다.

import Modal from './Modal';

const App = () => {
  const [isOpen, setOpen] = useState(false);

  const onClick = () => {
    setOpen(true);
  };

  return (
    <div className="App">
      <button onClick={onClick}>open Modal</button>
      <Modal isOpen={isOpen} />
    </div>
  );
};

export default App;

필요한 컴포넌트 안에서 모달 컴포넌트를 랜더링 하면 된다.

 

하지만 이 방법은 모달이 언제나 최상위에 보여지는 것을 보장하지 못한다. 만약 자식 컴포넌트에서 모달 컴포넌트를 랜더링 한다면 이 모달 컴포넌트는 부모 컴포넌트 스타일의 영향을 받을 수 있기 때문이다.

 

 

 

 

 

 

 

이럴 때 사용하는 것이 potal이다.

 

공식 홈페이지에서는 potal에 대해 이렇게 설명한다.

Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링 하는 최고의 방법을 제공합니다.

 

 

사용법은 간단하다.

ReactDOM.createPortal(child, container)

첫 번째 인자 child는 포탈을 사용해 계층 밖으로 보낼 대상이 되는 컴포넌트,

두 번째 인자는 container 는 포탈로 이동할 목적지, 즉 child를 랜더링 할 DOM Element를 넣어준다.

 

 

 

 

Modal 만들기

먼저 버튼을 누르면 모달창이 열리는 간단한 앱을 만들어보았다.

// components/Modal/Modal

import React from "react";
import styled from "styled-components";
import image from "./assets/cat.png";

function Modal({ onClose }) {
  const handleClose = () => {
    onClose?.();
  };
  return (
      <Overlay>
        <ModalWrap>
          <CloseButton onClick={handleClose}>
            <i className="fa-solid fa-xmark"></i>
          </CloseButton>
          <Contents>
            <img src={image} alt="smile" />
            <h1>This is a Modal Dialog</h1>
            <Button onClick={handleClose}>Close</Button>
          </Contents>
        </ModalWrap>
      </Overlay>
  );
}

const Overlay = styled.div`
  position: fixed;
  width: 100%;
  height: 100%;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.2);
  z-index: 9999;
`;

const ModalWrap = styled.div`
  width: 600px;
  height: fit-content;
  border-radius: 15px;
  background-color: #fff;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
`;

const CloseButton = styled.div`
  float: right;
  width: 40px;
  height: 40px;
  margin: 20px;
  cursor: pointer;
  i {
    color: #5d5d5d;
    font-size: 30px;
  }
`;

const Contents = styled.div`
  margin: 50px 30px;
  h1 {
    font-size: 30px;
    font-weight: 600;
    margin-bottom: 60px;
  }
  img {
    margin-top: 60px;
    width: 300px;
  }
`;
const Button = styled.button`
  font-size: 14px;
  padding: 10px 20px;
  border: none;
  background-color: #ababab;
  border-radius: 10px;
  color: white;
  font-style: italic;
  font-weight: 200;
  cursor: pointer;
  &:hover {
    background-color: #898989;
  }
`;
export default Modal;

 

// App

import { useState } from "react";
import styled from "styled-components";
import Modal from "./components/Modal";

function App() {
  const [isOpen, setIsOpen] = useState(false);

  const onClickButton = () => {
    setIsOpen(true);
  };

  return (
    <AppWrap>
      <Button onClick={onClickButton}>Click Me !</Button>
      {isOpen && (<Modal
        open={isOpen}
        onClose={() => {
          setIsOpen(false);
        }}
      />)}
    </AppWrap>
  );
}

const Button = styled.button`
  font-size: 14px;
  padding: 10px 20px;
  border: none;
  background-color: #fa9f98;
  border-radius: 10px;
  color: white;
  font-style: italic;
  font-weight: 200;
  cursor: pointer;
  &:hover {
    background-color: #fac2be;
  }
`;

const AppWrap = styled.div`
  text-align: center;
  margin: 50px auto;
`;
export default App;

 

버튼을 누르면, 모달창이 나타난다.

 

 

코드를 보면, 모달창이 앱의 하위에 생성되는 것을 볼 수 있다.

 

이제 createPotal을 사용해 모달 컴포넌트를 이동시켜보자.

 

 

 

 

 

 

createPortal을 사용한 Modal 만들기

 

우선 모달 컴포넌트가 랜더링 될 DOM element를 index.html파일에 추가해준다.

<!DOCTYPE html>
<html lang="ko">
  ...
  <body>
    <div id="root"></div>
    <div id="modal"></div>
  </body>
</html>

 

만약 하나의 다이얼로그만을 사용하거나 앱을 사용하면서 모달창을 띄울 일이 많지 않다면 굳이 html파일에 modal요소를 생성해 놓을 필요는 없다.

하지만 대부분의 프로젝트에서는 다이얼로그를 비롯하여 alert이나 comform창을 띄울일이 빈번하기 때문에 modal 요소를 미리 생성해 두는 것이 좋다.

 

 

 

 

// components/Modal/ModalContainer

import React from "react";
import { createPortal } from "react-dom";

function ModalContainer({ children }) {
  return createPortal(<>{children}</>, document.getElementById("modal"));
}

export default ModalContainer;
import React, { useEffect } from "react";
import styled from "styled-components";
import image from "./assets/cat.png";
import ModalContainer from "./ModalContainer";

function Modal({ onClose }) {
  const handleClose = () => {
    onClose?.();
  };

  return (
    <ModalContainer>
      <Overlay>
        <ModalWrap>
          <CloseButton onClick={handleClose}>
            <i className="fa-solid fa-xmark"></i>
          </CloseButton>
          <Contents>
            <img src={image} alt="smile" />
            <h1>This is a Modal Dialog</h1>
            <Button onClick={handleClose}>Close</Button>
          </Contents>
        </ModalWrap>
      </Overlay>
    </ModalContainer>
  );
}

export default Modal;

ModalContainer를 만들고 이전의 Modal 컴포넌트를 ModalContainer로 감싸준다.

 

 

동작은 동일하지만 modal요소 하위에 컨테이너가 생성된 것을 볼 수 있다.

 

 

 

 

 

 

 

background 클릭 시 모달창 닫기

모달창은 자신 이외의 기능을 제한하기 때문에, 사용자가 원할 때 손쉽게 모달창을 빠져나갈 수 있어야 한다.

이를 위해 자주 사용되는 방법은 모달창 오른쪽 위의 X 버튼, 하단의 닫기 버튼, 그리고 모달창의 바깥을 클릭하면 창을 닫히도록 만드는 것이다.

 

 

모달창의 바깥을 클릭하면 창이 닫히도록 구현해보자.

 

import { useEffect } from "react";

function useOutSideClick(ref, callback) {
  useEffect(() => {
    const handleClick = (event) => {
      if (ref.current && !ref.current.contains(event.target)) {
        callback?.();
      }
    };

    window.addEventListener("mousedown", handleClick);

    return () => window.removeEventListener("mousedown", handleClick);
  }, [ref, callback]);
}

export default useOutSideClick;

useOutSideClick 이라는 커스텀 훅을 만들어준다.

이 훅은 ref 로 지정한 요소의 밖을 클릭 시 callback함수를 실행시켜준다.

 

 

 

import React, { useEffect, useRef } from "react";
import styled from "styled-components";
import useOutSideClick from "../../hooks/useOutSideClick";
import image from "../../assets/cat.png";
import ModalContainer from "./ModalContainer";

function Modal({ onClose }) {
  const modalRef = useRef(null)
  const handleClose = () => {
    onClose?.();
  };

  useOutSideClick(modalRef, handleClose);

  return (
    <ModalContainer>
      <Overlay>
        <ModalWrap ref={modalRef}>
          ...
        </ModalWrap>
      </Overlay>
    </ModalContainer>
  );
}

export default Modal;

useRef 를 사용해 Modal 컨테이너 요소를 담아주고 Modal 컨테이너의 외부를 클릭하면 handleClose 를 호출하도록 설정해주었다.

 

 

 

 

 

 

 

 

 

 

 

외부 스크롤 막기

 

 

콘텐츠가 창의 크기를 오버하여 스크롤이 생성된 경우,

모달창이 나타나도 외부 스크롤은 여전히 동작하는 것을 볼 수 있다.

 

 

 

function Modal({ onClose }) {

  const handleClose = () => {
    onClose?.();
  };

  useEffect(() => {
    const $body = document.querySelector("body");
    const overflow = $body.style.overflow;
    $body.style.overflow = "hidden";
    return () => {
    	$body.style.overflow = overflow
    };
  }, []);

  return (
    <ModalContainer>
      <Overlay>
        <ModalWrap>
          <CloseButton onClick={handleClose}>
            <i className="fa-solid fa-xmark"></i>
          </CloseButton>
          <Contents>
            <img src={image} alt="smile" />
            <h1>This is a Modal Dialog</h1>
            <Button onClick={handleClose}>Close</Button>
          </Contents>
        </ModalWrap>
      </Overlay>
    </ModalContainer>
  );
}

모달 랜더링 시 body의 overflow 값을 hidden 으로 설정하여 스크롤을 막아주고,

모달이 닫히면 다시 스크롤이 생성되도록 설정해주었다.

 

 

 

 

 

 

 

재사용 가능한 모달 컴포넌트로 바꾸기

 

보통 모달창은 안의 내용만 상이할 뿐, 기본 UI나 기능은 동일하다. 따라서 프로젝트에서 사용할 모달의 틀을 만들어서 재사용하는것이 훨씬 효율적이다.

import React, { useEffect, useRef } from "react";
import styled from "styled-components";
import useOutSideClick from "../../hooks/useOutSideClick";
import ModalContainer from "./ModalContainer";

function Modal({ onClose, children }) {
  const modalRef = useRef(null);
  const handleClose = () => {
    onClose?.();
  };

  useOutSideClick(modalRef, handleClose);
  useEffect(() => {
    const $body = document.querySelector("body");
    $body.style.overflow = "hidden";
    return () => ($body.style.overflow = "auto");
  }, []);
  return (
    <ModalContainer>
      <Overlay>
        <ModalWrap ref={modalRef}>
          <CloseButton onClick={handleClose}>
            <i className="fa-solid fa-xmark"></i>
          </CloseButton>
          <Contents>{children}</Contents>
        </ModalWrap>
      </Overlay>
    </ModalContainer>
  );
}

export default Modal;

<Contents> 안에 들어있던 내용을 지우고 children 을 받아서 랜더링 하도록 바꿔주었다.

 

 

 

import React from "react";
import Modal from "./Modal";
import styled from "styled-components";
import image from "../../assets/cat.png";

function FirstModal({ onClose }) {
  return (
    <Modal onClose={onClose}>
      <img src={image} alt="smile" />
      <h1>This is a Modal Dialog</h1>
      <Button onClick={onClose}>Close</Button>
    </Modal>
  );
}

...

export default SampleModal;
import React from "react";
import Modal from "./Modal";
import styled from "styled-components";

function SecondModal({ onClose }) {
  return (
    <Modal onClose={onClose}>
      <TextWrap>
        <h2> Hello !</h2>
        <p>
          ...
        </p>
      </TextWrap>
    </Modal>
  );
}

export default SecondModal;

위와 같이 Modal 안에 내용을 작성해 보다 간편하게 모달창을 생성할 수 있다.

 

 

 

 

import { useState } from "react";
import styled from "styled-components";
import FirstModal from "./components/Modal/FirstModal";
import SecondModal from "./components/Modal/SecondModal";

function App() {
  const [isOpen1, setIsOpen1] = useState(false);
  const [isOpen2, setIsOpen2] = useState(false);

  const onClickButton1 = () => {
    setIsOpen1(true);
  };

  const onClickButton2 = () => {
    setIsOpen2(true);
  };

  return (
    <AppWrap>
      <Button onClick={onClickButton1}>Click Me !</Button>
      <Button className="blue" onClick={onClickButton2}>
        Click Me !
      </Button>
      {isOpen1 && (
        <FirstModal
          onClose={() => {
            setIsOpen1(false);
          }}
        />
      )}
      {isOpen2 && (
        <SecondModal
          onClose={() => {
            setIsOpen2(false);
          }}
        />
      )}
      ...
    </AppWrap>
  );
}

export default App;

만든 두 개의 모달창을 App 컴포넌트에 랜더링 해주었다.

 

 

 

 

 

생각한 대로 잘 동작하는 것을 확인할 수 있다!

 

 

 

 

하지만 이렇게 한 컴포넌트에 여러 개의 모달을 띄우는 코드를 보면 의문이 들게 된다.

만약 한 컴포넌트에 5개의 모달을 띄워야 한다면, isOpen state를 5개 설정해줘야 하는 걸까? 이게 최선의 코드일까?

 

 

이제 우리는 모달을 열고 닫는 상태를 호출한 컴포넌트 내에서 관리하는 것이 아닌, 전역으로 관리하도록 코드를 바꿔야 한다.

여러 개의 모달을 효과적으로 관리하는 방법에 대해서는 다음 포스팅에서 다뤄보도록 하겠다.

 

 

 

 

 

다음 포스팅으로 넘어가기✨

 

[React] 효율적으로 모달 관리하기

무려 3개월 만에 돌아왔습니다... 이 글은 Portal을 사용한 모달창 만들기에서 이어집니다. 모달을 필요한 컴포넌트에서 그때그때 렌더링 하는 방법은 불필요한 코드를 늘리며, 각각의 컴포넌트에

leego.tistory.com