본문 바로가기

Front-End

[React/Next.js] 모달 컴포넌트 만들기

이전에 모달 컴포넌트는 Mui의 Modal을 조금만 커스텀해서 사용했었는데 이번에는 직접 만들어보았다...!

 

모달(팝업)창은 페이지 전체를 덮을 수 있도록 화면의 최상단에 위치해야 한다.

그래서 Next.js 앱의 가장 최상단 레이아웃에 페이지들이 렌더링 되는 영역 위로 modal-root를 만들어 어디서든 그 modal-root에 모달이 렌더링되도록 해 볼 것이다.

 

app 폴더 루트에 있는 layout.tsx

import './layout.css';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <div id="main-layout">
          <div id="root">{children}</div>
          <div id="modal-root" />
        </div>
      </body>
    </html>
  );
}

 


그리고 Modal.tsx 에서 부모 컴포넌트 바깥의 DOM 노드(modal-root div)에 렌더링하려면 ReactDom.createPortal을 사용하면 된다.

 

 

ReactDom.createPortal(child, container)

 

첫 번째 인자 child는 포탈을 사용해 밖으로 내보낼 컴포넌트 = 모달 컴포넌트,
두 번째 인자 container는 포탈로 이동할 목적지, 즉 child를 랜더링 할 DOM 요소 = modal-root를 넣어준다.

 

그리고 Modal 컴포넌트에는 모달의 열림 여부인 isOpen과 모달을 닫는 함수 onClose를 전달했다.

모달이 isOpen 상태이면 'modal-root'의 className에 'visible'을 추가해서 모달이 보이도록, isOpen상태가 아니면 'visible'을 삭제해서 모달이 더 이상 보이지 않도록 만들어 줄 것이다.

 

css 코드는 이렇게..

#main-layout {
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
}

#root {
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
}

#modal-root {
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  width: 100%;
  height: 100%;
  z-index: 3000;
  visibility: hidden;
  opacity: 0;
  transition: opacity 0.3s ease;
}

#modal-root.visible {
  opacity: 1;
  visibility: visible;
}

 

 

1차 완성된 Modal.tsx

'use client';

import React, { useState, useEffect } from 'react';
import ReactDom from 'react-dom';
import * as styles from './Modal.css';

const Modal = ({
  isOpen,
  onClose,
  children,
}: ModalProps) => {
  const [modalRoot, setModalRoot] = useState<HTMLElement | null>(null);

  useEffect(() => {
    setModalRoot(document.getElementById('modal-root'));
  }, []);


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

  useEffect(() => {
    if (modalRoot) {
      if (isOpen) {
        modalRoot.classList.add('visible');
      } else {
        modalRoot.classList.remove('visible');
      }
    }
  }, [isOpen, modalRoot]);



  if (!modalRoot) return null;


  return ReactDom.createPortal(
    isOpen ? (
      <div className={styles.modalBackdrop} onClick={handleClose}>
        <div
          className={styles.modalContainer}
          onClick={(e) => e.stopPropagation()}
        >
          {children}
        </div>
      </div>
    ) : null,
    modalRoot,
  );
};

export default Modal;

 

컴포넌트가 마운팅될 때, modal-root 요소를 찾아 modalRoot 상태에 할당하고 createPortal에 두번째 인자로 전달한다. 

isOpen상태가 변할 때 마다는 modalRoot에 visible 클래스를 추가하거나 제거해준다.

(Next.js의 서버 사이드 렌더링에서는 DOM 요소를 직접 읽어올 수 없기 때문에, modalRoot가 null일 때 발생할 수 있는 오류를 방지하기 위해 modalRoot를 의존성 배열에 추가하고 if (modalRoot) 조건문으로 체크했다.)

 

모달 배경이 되는 backdrop div영역을 클릭하면 모달이 닫히도록 onClick에 handleClose함수를 전달해주되 그 안에 있는 modalContainer div 영역을 클릭했을 때는 이벤트 버블링으로 handleClose가 실행되지 않도록 e.stopPropagation()를 넣어주었다.

 

 

 

닫기 금지 옵션 & 타이밍 기능 추가

 

프로젝트에서 사용할 모달들이 로딩이 끝나거나 n초가 지나야 닫을 수 있는 것들이 대부분이라 ModalProps에 timerSec과 closeBlocked 옵션도 추가해주었다.

export type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
  timerSec?: number;
  closeBlocked?: boolean;
};

 

timerSec를 timer 상태로 받아 모달이 열리면 타이머가 시작될 수 있게 하고

const [timer, setTimer] = useState<number>(timerSec || -1);

useEffect(() => {
    if (isOpen && timerSec && timer > 0) {
      const intervalId = setInterval(() => {
        setTimer((prevTimer) => prevTimer - 1);
      }, 1000);

      return () => clearInterval(intervalId);
    }
  }, [isOpen, timerSec, timer]);
  
  
  // 모달이 열릴 때마다 타이머 초기화
  useEffect(() => {
    setTimer(timerSec || -1);
  }, [isOpen]);

 

 

handleClose함수도 이렇게 바꿔주었다.

const handleClose = () => {
    closeBlocked || timer > 0 ? () => {} : onClose();
  };

 

 

사용 예시

 

 

 

 

+ useModal 커스텀 훅

 

useModal Hook을 만들면 모달 컴포넌트를 사용할 때 훨씬 편해진다. 

import { useState } from 'react';
import Modal from '../_components/Modal/Modal';

const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);

  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);

  return { Modal, isOpen, openModal, closeModal };
};

export default useModal;

 

모달을 사용하려는 페이지에서 import useModal만 해주면 필요한 요소들을 다 가져올 수 있을 것이다.