blog-imgDucklog

createPortal을 이용한 커스텀 모달 만들기 with Next 14 app dir

createPortal을 이용한 커스텀 모달 만들기 with Next 14 app dir

Portal이란?

Portal은 리액트 프로젝트에서 컴포넌트를 렌더링하게 될 때, UI 를 어디에 렌더링 시킬지 DOM을 사전에 선택하여 부모 컴포넌트의 바깥에 렌더링 할 수 있게 해주는 기능이다. 그래서 모달 같이 최상단에 띄어줘야 할 때 사용하면 유용하다.

우선, 해당 모달을 띄울 DOM을 만들어줘야한다.

next 환경이기 때문에 RootLayout에 만들어준다.

body 태그 안에  id가 "modal-root"인 div를 만들어줘서 해당 div으로 DOM을 선택할 수 있게 해준다.


export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className="bg-gray-50">
      <body className="md:max-w-[375px] w-full relative m-auto min-h-screen bg-white p-0 font-sans lining-nums text-gray-900 outline-none">
        <div id="modal-root" />
        {children}
        <ToastContainer position="top-right" autoClose={5000} />
      </body>
    </html>
  );
}

그리고, 모달 외부는 약간 어둡게 나와야하기때문에 모달 밖을 어둡게 해줄 BackDrop 컴포넌트를 만들어준다.


'use client';
interface BackdropProps {
  isOpen: boolean;
  onClick: () => void;
}

export default function Backdrop({ isOpen, onClick }: BackdropProps) {
  return (
    <div
      hidden={!isOpen}
      onClick={onClick}
      className={`${isOpen ? '' : 'hidden'} fixed inset-0 z-50 h-full w-full bg-black opacity-50`}
    />
  );
}

그리고 모달 컴포넌트를 만들어준다. 여기에서 createPortal을 사용해주는데, 우리가 아까 만들었던 div를 선택해주기 위해서 getElementById를 이용해서 해당 div을 선택해준다. 그리고 createPortal을 사용할 때 선택한 DOM id를 넣어준다.


'use client';

import { useState, useEffect, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import Backdrop from './Backdrop';

export default function Modal({
  children,
  isOpen,
  onClose,
  style,
  className,
}: PropsWithChildren<any>) {
  const [mounted, setMounted] = useState(false);
  const modalRoot = document.getElementById('modal-root') || document.body;

  useEffect(() => setMounted(true), []);

  return mounted
    ? createPortal(
        <>
          <Backdrop isOpen={isOpen} onClick={onClose} />
          <div
            hidden={!isOpen}
            style={{ ...style, display: isOpen ? undefined : 'none' }}
            className={`fixed inset-0 top-56 z-50 mx-auto h-fit w-10/12 overflow-y-auto rounded-lg bg-white md:max-w-[375px] ${className}`}
          >
            {children}
          </div>
        </>,
        modalRoot,
      )
    : null;
}

이제 커스텀 훅을 이용해서 모달을 조작하기 편하게 해줄 것이다. 모달은 단순히 화면에 오픈되거나, 모달이 닫히거나 하는 역할만 해주면 되기 때문에 모달을 오픈해주는 함수, 모달을 닫아주는 함수, 모달의 상태값만 있으면 된다.


'use client';
import React, { useState } from 'react';

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

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

  return { isOpen, openModal, closeModal };
};

export default useModal;

이런식으로 커스텀 훅을 만들어주면 모달을 사용할 때마다 useState로 상태를 만들어주지 않아도 되기 때문에 편하다. 이제 해당 모달을 사용해보자!


export default function PetInfoPage() {
  const { isOpen, openModal, closeModal } = useModal();
  return (
    <>
      <Modal isOpen={isOpen} onClose={closeModal}>
        <div className="p-10 flex items-center justify-center flex-col">
          <span className="p-10">테스트 모달입니다!</span>
          <div className="flex">
            <button onClick={closeModal}>닫기</button>
          </div>
        </div>
      </Modal>
      <label onClick={openModal}>종 </label>
      ....생략
    </>
  );
}

실행 화면