Recoil과 hook으로 global 모달 만들기
Tags: react, hook, recoil, modal
Published: 2021-02-21 12:35:45

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

## modal store가  필요한 이유?

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

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

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

하지만 만약에 하나의 컴포넌트에서 4~5개의 모달이 사용된다고 가정을 해보자. 
```javascript
  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);
```
이런 식으로 일일이 모달의 state를 관리해줘야 해주고, 함수의 open 과 close에 관련된 함수들을 모달에 props로 내려줘야 된다. 이렇게 되면 컴포넌트가 관리해야 하는 `state`가 굉장히 많이 늘어나야 하고 결국 코드에 대한 가독성이 안 좋아지고 관리하기가 어려워진다.

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

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

## 모달종류

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

1. **basicModal**

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

2. **twoBtnModal**

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

3. **customModal**

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

##렌더링

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

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

```javascript
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;

``` 

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

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

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

## Modal Store

```javascript
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: [],
});

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

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

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

```javascript
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,
};

```
위에 코드는 @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 훅
```javascript
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,
  };
}

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

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

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