DEV Community

leo
leo

Posted on

Recoil과 hook으로 global 모달 만들기

최근에 회사에서 material UI를 사용해서 admin 사이트를 개발하고 있다. 백엔드에 api를 연동하기 전에 UI 작업을 하던 중에 모달을 사용하는 일이 많아져서, 글로벌 modal store를 만들어보게 됐다.

modal store가 필요한 이유?

사실 여러 개의 모달들을 하나의 공용 컴포넌트 모달로 계속 사용이 가능하다면 modal store가 필요할 이유는 굳이 없다. 다만 모달을 사용하게 되면 기본 모달이 아닌 커스텀 모달들이 계속 추가되는 경우가 많다.

예를 들면, 모달 안에 버튼이 4개가 들어간다든지, 아니면 사용자가 입력을 할 수 있는 input창이 필요 한다든지, 이미지를 렌더링해야 된다는 등등. 이런 모달들 같은 경우는 커스텀으로 따로 컴포넌트를 만들어서 관리를 해줘야 한다.

이렇게 만들어진 모달들을 렌더링을 하려면 보통 useState를 이용해서 모달의 state를 사용한다.

하지만 만약에 하나의 컴포넌트에서 4~5개의 모달이 사용된다고 가정을 해보자.

  const [isModalOpen, setModalOpen] = useState(false);
  const [isModal1Open, setModa1lOpen] = useState(false);
  const [isModal2Open, setModal2Open] = useState(false);
  const [isModal3Open, setModal3Open] = useState(false);
  const [isModal4Open, setModal4Open] = useState(false);
Enter fullscreen mode Exit fullscreen mode

이런 식으로 일일이 모달의 state를 관리해줘야 해주고, 함수의 open 과 close에 관련된 함수들을 모달에 props로 내려줘야 된다. 이렇게 되면 컴포넌트가 관리해야 하는 state가 굉장히 많이 늘어나야 하고 결국 코드에 대한 가독성이 안 좋아지고 관리하기가 어려워진다.

또한 어떤 상황에서는 모달 안에서 다시 모달을 열어줘야되는 경우가 있을 수가 있으며, 모달안에서 다른 모달에게 props로 콜백 함수를 넘겨줘야 하는 경우도 있다.

이런 경우에 global로 modal store를 만들어서 한군데에서 모든 모달을 관리를 해주면 굉장히 편하게 사용을 할 수가 있다.

모달종류

먼저 모달들의 종류를 케이스 별로 나누어서 분리를 해봤다.

  1. basicModal

가장 기본적인 모달이며, 텍스트 외에 특별하게 렌더링이 필요하지 않고, 확인 버튼을 누르면 닫히는 모달이다.

  1. twoBtnModal

basicModal 버튼이 하나 더 추가된 모달이다. 오른쪽 버튼 같은 경우는 클릭이 됐으면 콜백 함수가 실행되면서 모달이 닫힌다.

  1. customModal

위에 두 가지에 포함되지 않은 모달들이며, 이 모달들 같은 경우는 재사용이 불가능하기 때문에 각각 컴포넌트를 만들어 줘야 한다.

렌더링

글로벌 모달의 렌더링 같은 경우는 앱의 최상단인 App에서 에서 Modal를 import를 해올것이고 Modal 파일이 modalList를 가지고와서 map method로 렌더링을 해주는 방식으로 구현.

이렇게 Modal에서 리스트에 있는 모든 모달들이 렌더링이 될 것이다. 보통 모달을 렌더링할 때 isOpen이라는 boolean값으로 모달 렌더링을 제어를 하는데, 번거롭게 스테이트를 선언해서 넘겨줘야 하는 부분이 사라지게 된다.

import { FC } from 'react';
import { useRecoilState } from 'recoil';
import { modalState } from '@state/modal';
import BasicModal from '@molecules/modal/basicModal';
import TwoBtnModal from '@molecules/modal/twoBtnModal';
import { isBasicModal, isTwoBtnModal, isCustomModal } from '@typeGuard/guard';
import { customModal } from '@molecules/modal/customModal';

const Modal: FC = () => {
  const [modalList, setModalList] = useRecoilState(modalState);

  return (
    <div>
      {modalList.map(({ key, props }, index) => {
        if (isBasicModal(props)) {
          return <BasicModal {...props} key={key + String(index)} />;
        }

        if (isTwoBtnModal(props)) {
          return <TwoBtnModal {...props} key={key + String(index)} />;
        }

        if (isCustomModal(key)) {
          const CustomModal = customModal[key];
          return <CustomModal {...props} key={key} />;
        }

        return null;
      })}
    </div>
  );
};

export default Modal;

Enter fullscreen mode Exit fullscreen mode

타입 가드 함수를 이용해서 props가 타입 추론이 안돼서 에러가 나는 부분을 해결했다. 커스텀 모달일 경우에는 customModal(컴포넌트가 저장되어 이는 객체), 해당 키값에 맞는 컴포넌트를 렌더링을 시켜줄 수 있게 했다.

사실 props를 내려줄 때 spread를 이용해서 내려주고 싶지 않았지만, spread를 사용해주지 않으면, 각각의 커스텀 모달에 맞는 타입 가드를 이용해서 타입 추론을 정확하게 시켜야 되서, 나중에 커스텀 모달이 많아지면, 그때마다 타입 가드를 넣어주고 if문을 써야 되기 때문에 고민 끝에 spread operaor를 사용했다.

다만 리엑트 공식 홈페이지에서는 spread 를 이용해서 props를 내려주는 걸 권장하지 않기 때문에, 이 부분은 다시 한번 생각해봐야 할 거 같다.

Modal Store

import { atom } from 'recoil';
import { Props as BasicMoalProps } from '@molecules/modal/basicModal';
import { Props as TwoBtnModalProps } from '@molecules/modal/twoBtnModal';
import { Props as UserBlockModalProps } from '@molecules/modal/customModal/userBlockModal';
import { CustomModalKey } from '@molecules/modal/customModal';

export type ModalKey = 'basicModal' | 'twoBtnModal' | CustomModalKey;
export type ModalProps = BasicMoalProps | TwoBtnModalProps | UserBlockModalProps;

export interface Modal {
  key: CustomModalKey | ModalKey;
  props: ModalProps;
}

export const modalState = atom<Modal[]>({
  key: 'modalState/modal',
  default: [],
});

Enter fullscreen mode Exit fullscreen mode

recoil을 이용해서 글로벌 모달에 대한 state를 만들어줬다. 모달 리스트는 배열 안에 객체로 저장되는 형태이며, key와 props라는 속성을 가지고 있다.

key 같은 경우는 type을 이용해서 basicModaltwoBtnModal과 커스톰 모달들의 키값만 들어올 수 있게 제한을 해두었다. Prps같은 경우에도 basic과 twBtn 모달과 커스텀 모달의 props만 들어올 수 있도록 제한해 두었다.

만약에 커스텀 모달이 더 추가가 되면 각각의 props는 import 해와서 ModalProps에 추가를 해두어야 한다.

import React from 'react';
import UserBlockModal from './userBlockModal';

export const customModalKey = ['userBlockModal'] as const;
export type CustomModalKey = typeof customModalKey[number];

type CustomModal = {
  [key in CustomModalKey]: React.ElementType;
};

export const customModal: CustomModal = {
  userBlockModal: UserBlockModal,
};

Enter fullscreen mode Exit fullscreen mode

위에 코드는 @customModal/index.ts 파일이다.

const assertions을 이용해서 커스텀 모달의 키값들을 배열 안에 넣어 두었다.
이 방법을 사용할 경우에 배열을 읽기 전용 tuple로 만들어 준다. 이 배열에 있는 값들을 union type으로 만들어 주기가 아주 편하다. const assertions에 대해서 자세히 알고 싶으면 아래 링크를 참조하면 된다.

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html

customModal 객체안에는 커스텀 모달들의 키값들이 속성값이 되며 커스텀 컴포넌트들이 value로 저장이 된다.

useModal 훅

import { useRecoilState } from 'recoil';
import { modalState, Modal } from '@state/modal';

interface UseModal {
  addModal: ({ key, props }: Modal) => void;
  removeCurrentModal: () => void;
}

export default function useModal(): UseModal {
  const [modalList, setModalList] = useRecoilState(modalState);

  const addModal = ({ key, props }: Modal) => {
    const newModalList = [...modalList];
    newModalList.push({ key, props });
    setModalList(newModalList);
  };

  const removeCurrentModal = () => {
    const newModalList = [...modalList];
    newModalList.pop();
    setModalList(newModalList);
  };

  return {
    addModal,
    removeCurrentModal,
  };
}

Enter fullscreen mode Exit fullscreen mode

모달을 추가하거나, 제거하는 함수 같은 경우는 재사용이 계속될 함수이므로 useModal이라는 커스텀 훅을 만들었다. 모달을 추가를 할 때는 key와 props가 있는 객체를 인자로 넣어주면 된다.

모달을 제거를 할 때는 따로 인자를 넣어줄 필요는 없다. 모달 리스트에서 가장 마지막에 있는 모달을 제거하기 때문에 현재 렌더링 되어있는 모달이 close가 된다.

만약에 redux를 사용하는 거라며 hook을 사용하기보다는, action 함수를 만들어서 dispatch를 실행해주면 된다.

Top comments (0)