모달을 만드는 다양한 방법들

Zero·2024년 6월 9일
1

[React]

목록 보기
5/6
post-thumbnail

모달이란?

사용자의 화면을 일시 중지하고 집중해서 보여지는 새 영역을 말합니다.

이를 통해 스크롤등의 사용자의 화면 변화를 막아둔 상태로 모달창에 집중되어 사용자에게 맞는 플로우(알림, 로그인)를 제공 할 수 있습니다.

State만을 이용하여 모달을 구현하는 방법

이전에 리액트를 처음 했을 당시 구현은 다음과 같이 이루어졌습니다.

const Page = () => {
  const [isModalOpened, setIsModalOpened] = useState(false)
  
  const onClickModal = () => {
  // ..로직
  }
  
  const onCloseModal = () => {
  // ..로직
  }
  
  return (
    <section>
      {isModalOpened && <Modal onClose={onCloseModal}>Default Modal />}
      <button onClick={onClickModal}>
        모달 열기
      </button>
    </section>
  );
};

export default DefaultModal;

하지만 위와 같이 만들 경우 문제를 생각 해볼수도 있습니다.

  1. 모달의 수가 늘어날 수록 관리해야할 state 값이 많아집니다.

  2. 관리해야할 handler 함수도 늘어납니다.

만약 관리해야 하는 모달이 많다고 가정을 해보겠습니다.

import { useState } from 'react';

const Page = () => {
  const [isLoginModalOpened, setIsLoginModalOpened] = useState(false);
  const [isLoginAlertModalOpened, setIsConfirmModalOpened] = useState(false);
  const [isConfirmModalOpened, setIsConfirmModalOpened] = useState(false);

  const onClickLoginModal = () => {
    // ..로직
  };

  const onCloseLoginModal = () => {
    // ..로직
  };

  const onClickLoginAlertModal = () => {
    // ..로직
  };

  const onCloseLoginAlertModal = () => {
    // ..로직
  };

  const onClickConfirmModal = () => {
    // ..로직
  };

  const onCloseConfirmModal = () => {
    // ..로직
  };
  return (
    <section>
      {isLoginModalOpened && <LoginModal onClose={onCloseLoginModal} />}
      {isLoginAlertModalOpened && <LoginAlertModal onClose={onCloseLoginAlertModal} />}
      {isConfirmModalOpened && <ConfirmModal onClose={onCloseConfirmModal} />}
      <button onClick={onClickLoginModal}>로그인 모달 열기</button>
      <button onClick={onClickLoginAlertModal}>로그인 안했을 때의 모달 열기</button>
      <button onClick={onClickConfirmModal}>확인 모달 열기</button>
    </section>
  );
};

export default Page;

모달 하나당 관리해야 하는 함수, 상태 각각 추가가 되기 때문에 이는 코드의 가독성이 안좋아지고 복잡해지는 코드가 만들어지는 것을 확인 할 수 있습니다.

ContextAPI를 이용하여 구현합니다.

// /store/ModalsProvider.tsx

import { useState, useMemo, ReactElement } from 'react';
import { ModalsDispatchContext, ModalsStateContext } from './ModalsContext';
import Modals from '../Components/Modals';

export const ModalsStateContext = createContext([]);

// useMemo를 이용하여 최적화를 하기위해 두개로 분리하여 context를 만들어줍니다.
export const ModalsDispatchContext = createContext({});


const ModalsProvider = ({ children }) => {
  const [openedModals, setOpenedModals] = useState([]);

  const open = (Component, props) => {
    setOpenedModals((prev) => [...prev, { Component, props, isOpen: true }]);
  };

  const close = (Component) => {
    setOpenedModals((prev) => prev.filter((item) => item.Component !== Component));
  };

  const dispatch = useMemo(() => ({ open, close }), []);

  return (
    <ModalsStateContext.Provider value={openedModals}>
      <ModalsDispatchContext.Provider value={dispatch}>
        <Modals />
        {children}
      </ModalsDispatchContext.Provider>
    </ModalsStateContext.Provider>
  );
};

export default ModalsProvider;

useMemo 함수를 이용하여 리렌더링 되는 것을 방지합니다.

/Components/Modals.tsx

const Modals = () => {
  const openedModal = useContext(ModalsStateContext);
  const { close } = useContext(ModalsDispatchContext);
  
  return (
    <div>
      {openedModal.map((modalInfo, index) => {
        const { Component, isOpen, props } = modalInfo;
        const onClose = () => {
          close(Component);
        };

        return <Component isOpen={isOpen} onClose={onClose} key={index} {...props} />;
      })}
    </div>
  );
};

저장되어있는 열려있는 Modal들을 map()을 이용하여 전부 렌더링 해줍니다. 또한 만들면서 onClose() 함수를 이용하여 해당 모달을 닫는 함수까지 미리 만들어줍니다.

import styles from './custom-modal.module.scss'

const CustomModal = ({ isOpen, onClose }) => {
  return (
    <div className={isOpen ? styles.modalOpen : styles.modalClosed}>
      <div>Custom Modal</div>
      <button onClick={onClose}>닫기</button>
    </div>
  );
};

export default CustomModal;
import './App.css';
import { useModals } from './hooks/useModals';
import CustomModal from './modal/CustomModal';

function App() {
  const { openModal } = useModals();

  const handlerOnClick = () => {
    openModal(CustomModal, { name: 'custom modal' });
  };

  return (
    <div className="App">
      <button onClick={handlerOnClick}>모달 열기</button>
    </div>
  );
}

export default App;

이제 useModal 함수를 이용하여 미리 만들어져있는 Modal인 CustomModal을 띄울 수 있습니다.또 2번째 인자로 props를 넘기기 때문에 모달간의 연결고리나 여러가지 정보들을 넘겨줄 수 있습니다.

Portal을 이용하여 모달을 구현합니다.

Portal을 이용하여 구현할 수도 있습니다.

import { useContext } from 'react';
import { ModalsDispatchContext, ModalsStateContext } from '../store/ModalsContext';
import ReactDOM from 'react-dom';
const Modals = () => {
  const openedModal = useContext(ModalsStateContext);
  const { close } = useContext(ModalsDispatchContext);

  return ReactDOM.createPortal(
    <div className="modal">
      {openedModal.map((modalInfo, index) => {
        const { Component, isOpen, props } = modalInfo;
        const onClose = () => {
          close(Component);
        };

        return <Component isOpen={isOpen} onClose={onClose} key={index} {...props} />;
      })}
    </div>,
    document.body
  );
};

export default Modals;

아까와 같이 Modals를 만들 때 ReactDom.createPortal() 함수를 실행하면 되는 것입니다.

또한 createPortal() 두번째 인자로서 렌더링을 할 위치를 정할 수 있습니다.

이를 이용하여 같은 레벨이 아닌 상위 Dom인 body안에 해당 컴포넌트가 그려지는 것입니다.

열리기 전의 모습

열린 후의 모습

만약에 특정 DOM에다가 만들고 싶다면 다음과 같이 할 수 있습니다.

index.html을 수정합니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
    <div id="react-modal" /> <!-- 추가된 react-modal -->
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

추가하고 싶은 DOM 요소를 가져옵니다.

import { useContext } from 'react';
import { ModalsDispatchContext, ModalsStateContext } from '../store/ModalsContext';
import ReactDOM from 'react-dom';
const Modals = () => {
  const modalParent = document.getElementById('react-modal');
  const openedModal = useContext(ModalsStateContext);
  const { close } = useContext(ModalsDispatchContext);

  return ReactDOM.createPortal(
    <div className="modal">
      {openedModal.map((modalInfo, index) => {
        const { Component, isOpen, props } = modalInfo;
        const onClose = () => {
          close(Component);
        };

        return <Component isOpen={isOpen} onClose={onClose} key={index} {...props} />;
      })}
    </div>,
    modalParent
  );
};

export default Modals;

이렇게 할 경우 modalParent 안에 해당 컴포넌트를 렌더링 시킬 수 있습니다.

모달을 구현하기에 ContextAPI, CreatePortal 무엇이 더 좋은 방법일까요?

이는 팀이나 회사의 컨벤션마다 다르겠지만 이번에 구현하면서 느낀 점은 CreatePortal이 더 적합하다고 느꼈습니다.

이유는 다음과 같습니다.

모달은 페이지와 독립적으로 구분이 되어야 합니다. 이는 화면 중앙에 위치해야 하기 때문입니다. DOM 트리의 다른 위치에 렌더링을 하기 위해서 CreatePortal 메소드를 제공해주기에 안할 이유가 없습니다.

반면 전역 상태 관리인 ContextAPI는 보통 전역으로 데이터를 관리하는데 주로 사용합니다. 이는 ContextApi가 너무 많은 일을 하는 것은 아닐까? 라는 생각을 할 수도 있습니다.

하지만 그렇다고 해서 CreatePortal이 맞다. 라는 것은 아닙니다. 여러 부분에서 공유되어야 하고 재사용이 된다면 이 또한 전역 변수를 활용한 ContextAPI를 활용해 보는 것이 좋을 것 같습니다.

profile
0에서 시작해, 나만의 길을 만들어가는 개발자.

0개의 댓글