모달, 어떻게 관리할까?

GY·2023년 5월 28일
0

리액트

목록 보기
54/54
post-thumbnail
post-custom-banner

모달, 어떻게 관리할까? Redux? Context API?

Redux로 관리

이전까지는 모달을 리덕스로 관리해왔습니다.

Context API로 관리해보자

하지만 실무에서 규모가 더 큰 프로젝트를 진행해보니 리덕스로 모달 상태를 관리하는 것에 대한 의문이 생기기 시작했습니다.
정답은 없겠지만, 리덕스로 관리할 필요가 없다고 생각했던 이유는 다음과 같습니다.

  1. 모달의 경우 관리해야 하는 상태가 비교적 단순함
  2. 리덕스는 하나의 스토어에 모든 상태를 관리하는 반면, context API는 관심사에 따라 상태를 분리해 전역 관리가 가능

물론, 리덕스와 context API를 비교하는 것은 적절하지 않을 수 있습니다.
Context API는 props drilling을 피해 props를 깊이 전달하기 위한 수단이고, Redux는 이 context API를 사용해 전역상태를 관리하기 위한 라이브러리 중 하나이니 서로 대체 가능한 관계라기 보다는 필요에 따라 선택해 사용하는 것이고, 리덕스와 context API를 한 프로젝트에서 혼용하기도 합니다.


Modal을 전역 위치에서 렌더링하기

React portal 사용

모달을 전역에서 상위에 렌더링 하기 위해 보통 React.portal을 사용합니다.
예를 들면, 이렇게 Portal을 통해 root요소와 대등한 최상위 레벨의 요소에서 전역적으로 렌더링되도록 감싸는 컴포넌트를 만들고,

const ModalPortal = ({ children }: { children: JSX.Element | boolean }) => {
  const el = document.getElementById("modal") as HTMLElement;
  return createPortal(children, el);
};

이 랩핑 컴포넌트의 자식요소로 modal 컴포넌트를 넣어 렌더링해줄 수 있습니다.

//Modal/index.tsx
return (
    <ModalPortal>
      {isModalOpened && (
        <S.DimmedBackground>
          <S.Container ref={clickOutsideRef}>
            {modalComponent[type] && modalComponent[type]}
          </S.Container>
        </S.DimmedBackground>
      )}
    </ModalPortal>
  );

React Modal 사용

이렇게 손수 만들어서 사용할 수도 있지만, ReactModal 라이브러리를 사용해 보다 간편하게 관리할 수도 있습니다.

  1. portal을 따로 선언해 사용하지 않아도 모달 컴포넌트를 전역적으로 렌더링할 수 있음
  2. 각종 스타일 커스터마이징 가능
  3. shouldFocusAfterRender, onRequestClose 등 모달 관련 함수 호출 시점을 다양하게 지정할 수 있음

개인적으로는 써보니 재사용할일이 많은 컴포넌트도 아닌만큼 portal 컴포넌트를 따로 만드는 것보다 라이브러리를 사용하는 편이 더 깔끔하다고 느꼈습니다. 게다가 복잡한 모달 동작을 구현해야 하는 경우 ReactModal의 props로 손쉽게 각 콜백함수 호출타이밍/조건 등에 맞춰 모달을 관리할 수 있습니다.


필요한 위치에서 모달 띄우기

useModal 커스텀 훅 만들기

openModal

필요한 모달 컴포넌트를 props와 함께 인자로 넘겨주면 필요한 곳에서 모달을 띄울 수 있습니다.

import loadable from "@loadable/component";
import useModal from "@/hooks/useModal";
const CardDetail = loadable(() => import("@components/Modals/CardDetail"));

const Card = ({ text }: Props) => {
  const { openModal } = useModal();

  const handleClick = () => {
    openModal({
      component: CardDetail,
      props: { title: "cardDetail" },
      options: { hasOverlay: true },
    });
  };

모달 컴포넌트 props 타입 자동추론하기

component의 인자로 컴포넌트를 직접 넘겨주는 이유는 자동 타입추론을 위해서였습니다.
아래와 같이 string타입의 type을 넘겨줄 수도 있는데, 그럴 경우 해당 모달 컴포넌트의 props에 대한 타입 검사가 이루어지지 않습니다.

즉, props 규격에 맞지 않는 인자들을 넘겨주어도 타입스크립트는 에러를 반환하지 못합니다.
= 타입스크립트를 사용하는 의미가 없어집니다!

import { MODAL_TYPE } from "@/constants";
import useModal from "@/hooks/useModal";
import * as S from "./style";

const Card = ({ text }: Props) => {
  const { openModal } = useModal();

  const onClickCard = () => {
    openModal({ type: MODAL_TYPE.CARD_DETAIL, props: { title: "cardDetail" } });

따라서 component의 타입을 React.ComponentType으로 지정해 props에 대한 타입체크를 진행하도록 했습니다.

loadable을 사용한 코드 스플리팅

그런데 여기서 또 하나, 생각해보아야 할 문제가 있었습니다.
1. 모달을 띄울 컴포넌트 내에서 일일히 띄울 모달 컴포넌트를 import해주어야 합니다.
2. 이에 더해 코드스플리팅을 위해 loadable을 사용했는데, 이 역시 사용하는 컴포넌트에서 중복으로 작성하게 되는 문제였습니다.

import loadable from "@loadable/component";
import useModal from "@/hooks/useModal";
const CardDetail = loadable(() => import("@components/Modals/CardDetail"));

const Card = ({ text }: Props) => {
  const { openModal } = useModal();

  const handleClick = () => {
    openModal({
      component: CardDetail,
      props: { title: "cardDetail" },
      options: { hasOverlay: true },
    });
  };

코드 스플리팅할 모달 컴포넌트를 한 곳에 모으기

string을 key로, 컴포넌트를 value로 모달 컴포넌트를 관리하는 객체를 만들었습니다.
이렇게 하면 이 한 곳에서 loadable을 사용한 코드 스플리팅을 하고, 사용할 위치에서는 따로 모달 컴포넌트를 import할 필요없이 string만 전달해주면됩니다.

//modalContents/index.tsx
import loadable from "@loadable/component";
import { ModalContents } from "@/interfaces/modal";

const CardDetailModal = loadable(() => import("@components/modals/CardDetailModal"));
const InviteToWorkspaceModal = loadable(() => import("@components/modals/InviteToWorkspaceModal"));

export const modalContents: ModalContents = {
  cardDetailModal: CardDetailModal,
  inviteToWorkspaceModal: InviteToWorkspaceModal,
};
 const handleClick = () => {
    openModal({
      component: "cardDetailModal",
      props: { title: data.description },
    });
  };

그러면 전달된 string key값으로 객체에 접근해 해당하는 컴포넌트를 렌더링합니다.

//Modal/index.tsx
const Modal = ({ component, index, onClose, props }: Props) => {

  const Component = modalContents[component];

  return (
    <S.ModalContainer>
      <Component {...props} onClose={handleClose} />
    </S.ModalContainer>
  );
};

자동 타입추론까지 챙기기

모달 컴포넌트 import문제는 개선되었으나, 이렇게 되면 다시 props 타입 추론이 자동으로 되지 않는 문제가 생긴다는 걸, 그만 깜빡해버렸습니다! 😫

한 곳에 모아 모달 컴포넌트를 관리하되 props 타입추론까지 가능하게 하려면 어떻게 해야할까요? 이 부분에서 여러 방식으로 고민하고 삽질도 많이하느라 시간을 많이 썼습니다.🥲

결국 모달 컴포넌트를 정의한 객체를 import해 value를 직접 넘겨주는 방식을 사용했습니다.
이렇게 되면 component의 인자로 넘겨주는 값은 다시 컴포넌트타입이 되고, React.ComponentType을 사용해 props로 넘겨주는 값에 대한 타입 체크가 가능해집니다.

export interface ModalState<TProps = any> {
  component: ComponentType<TProps>;
import { modals } from "@components/modals/components";
import * as S from "./style";

const handleClick = () => {
  openModal({
    component: modals.cardDetailModal,
    props: { title: data.description },
  });
};

다중모달 닫기

다중모달을 여는 것은 할 수 있는데, 이 것들을 차례차례 닫는 기능을 구현하는 것 또한 고민이 필요했습니다.

표시되어 있는 모달들을 배열로 관리하고, 닫기 버튼을 누른 모달은 filter해서 표시중인 모달에서 제외하도록 만들었습니다.
문제는 같은 컴포넌트를 다른 props를 넣어서 여러개를 띄우는 경우를 가정했을 때, 이 두 모달은 한번에 닫히게 된다는 것이었습니다. 따라서 component와 props까지 비교해 정확히 일치하는 모달만 닫도록 만들었습니다.

//useModal/index.tsx
  const closeModal = <TProps,>({ component, props }: ModalState<TProps>) => {
    modalDispatch.close({ component, props });
//ModalsProvider/index.tsx
  const close = useCallback((state: ModalState) => {
    setModals((modals) => {
      return modals.filter((modal) => {
        return modal.component !== state.component || modal.props !== state.props;
      });
    });

index로 관리해 불필요한 로직 없애기

modal컴포넌트를 띄울때 각 index값을 생성해주었습니다.

  const open = useCallback((state: OpenModalState) => {
    setModals((modals) => [...modals, { ...state, index: modals.length }]);

그리고 이 index만을 비교해 닫도록 만들면 불필요한 로직이 없어질 뿐더러 왜 component와 props를 비교하는지 불분명한 코드가 없어져 가독성도 개선됩니다.

  const close = useCallback((state: CloseModalState) => {
    setModals((modals) => modals.filter((modal) => modal.index !== state.index));

정리하자면 모달을 구현하는 과정에서 고려했던 사항들은 다음과 같습니다.

  • 전역 위치에서 렌더링이 가능한가
  • 한 곳에서 모달 컴포넌트를 정의하고 관리할 수 있는가
  • 코드 스플리팅을 한 곳에서 한번에 중복없이 할 수 있는가
  • 모달 컴포넌트 props에 대한 타입 추론을 자동으로 할 수 있는가

그리고...

이번 글에서는 전반적인 모달 렌더링에 대한 고민과 모달 컴포넌트를 관리하는 로직에 대한 고민을 주로 다루었습니다.

하지만 모달에 관해 가지고 있었던 조금 더 세부적인 고민이 아직 남아있는데요,
실무에서도 여러번 고민이 되었던 부분인데, 모달 컴포넌트를 변경/추가되는 UI에 어떻게 유연하게 대응하도록 만들 수 있을지가 궁금했습니다.

이 부분에 대해서는 다른 포스팅에서 정리했습니다.
보러가기

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.
post-custom-banner

0개의 댓글