확장성 및 재사용성이 높은 커스텀 모달 hook 만들기

pengooseDev·2023년 4월 2일
0
post-thumbnail

재사용성이 높은 모달을 간단하게 만들어보자.

Portal 생성

// ModalPortal.tsx
import { ReactNode } from 'react';
import ReactDom from 'react-dom';

interface ModalPortalProps {
  children: ReactNode;
}

export const ModalPortal = ({ children }: ModalPortalProps) => {
  const modalRoot = document.getElementById('modal-root') as HTMLElement;

  return ReactDom.createPortal(children, modalRoot);
};
// public/index.html

<body>
  <div id="root"></div>
  <div id="modal-root"></div>
</body>

포탈을 생성하는 이유는 간단하다. 우선 컴포넌트가 생성된 결과부터 확인하자.

root의 하위 컴포넌트에 modal을 제어하는 컴포넌트가 생성되는 것이 아닌, modal-root라는 별개의 컴포넌트에 생성된다.

하나의 컴포넌트에 로직을 작성하게 된다면, 여러 컴포넌트가 복잡하게 얽혀 예상치 못한 z-index issue를 마주할 수 있다. 하지만, 해당 솔루션을 적용하면 오로지 modal-root 내부의 컴포넌트들의 CSS와 root컴포넌트 내부에 z-index가 부여된 컴포넌트들만 고려하면 되기 때문에 예상치 못한 오류를 피할 수 있다.
또한, 기능에 따라 컴포넌트를 분류하였기 때문에 이후 확장성과 재사용성에 이득을 취할 수 있다.


구조는 간단하다. Modal로 띄울 컴포넌트를 props로 DI받고, modal의 창을 닫는 closeModal과 창이 열려있는 상태인 isOpen을 props로 전달받는다.

Modal내부에 isOpen 및 state를 관리하는 setter를 선언하는 방법도 있지만, 확장성이 낮다는 문제가 있다. 예를들어, Modal 컴포넌트 외부에서 특정 함수가 실행될 때(예를들어 모달창 내부에서 POST 메서드를 던지는 경우) Modal 컴포넌트의 state를 제어할 수 없다는 문제가 발생한다.

따라서, MVC패턴의 Controller가 DI받은 View 메서드의 매개변수에 콜백함수를 넘겨주어 View 메서드 내부에서 Controller로부터 전달받은 함수를 실행하듯 코드를 작성하여 해결했다.

import styled from 'styled-components';
import { ModalPortal } from 'components/modal';
import { AnimatePresence, motion } from 'framer-motion';
import { mountVariants, modalCardVariants } from 'libs';

interface ModalProps {
  children: React.ReactNode;
  isOpen: boolean;
  closeModal: () => void;
}

export function Modal({ children, isOpen, closeModal }: ModalProps) {
  const closeHandler = (e: React.MouseEvent<HTMLElement>) => {
    e.stopPropagation();
    closeModal();
  };

  return (
    <ModalPortal>
      <AnimatePresence>
        {isOpen ? (
          <Overlay
            onClick={closeHandler}
            variants={mountVariants}
            initial="from"
            animate="to"
            exit="exit"
          >
            <Wrapper
              variants={modalCardVariants}
              onClick={(e) => e.stopPropagation()}
            >
              {children}
            </Wrapper>
          </Overlay>
        ) : null}
      </AnimatePresence>
    </ModalPortal>
  );
}

useModal

이제 간단하게 사용할 수 있도록 커스텀 훅을 이용하여 로직들을 합치도록 한다.

import { useState } from 'react';
import { Modal } from 'components/modal';

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);

  return {
    Modal,
    isOpen,
    openModal,
    closeModal,
  };
};

Modal에 띄울 컴포넌트 생성

모달창에 띄우고싶은 컴포넌트를 생성한다.

import styled from 'styled-components';
import { useForm } from 'react-hook-form';
import { useState } from 'react';
import { Add } from 'assets/icons';
import { createProject } from 'api';
import { useMutation } from 'react-query';
import { sendToast } from 'libs';

export function CreateProject({ closeModal }: CreateProjectProps) {
  const [thumbnail, setThumbnail] = useState<File | null>(null);
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<TitleForm>({
    mode: 'onChange',
  });

  const { mutate } = useMutation(createProject, {
    onSuccess: () => {
      queryClient.invalidateQueries('userProjects');
      closeModal(); // 모달을 닫는다!
      sendToast.success('create the project!');
    },
    onError: () => {
      sendToast.success('failed to create project!');
    },
  });

  const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const formData = new FormData();
    //formData데이터 추가 코드
    mutate(formData);
  };

  return (
    <ModalContainer>
      //띄울 코드
    </ModalContainer>
  );
}

적용

import styled from 'styled-components';
import { NAVLINK } from 'constants/';
import { Add } from 'assets/icons';
import { useModal } from 'hooks';
import { CreateProject } from 'components/modal';

export function AddProject() {
  const { Modal, isOpen, openModal, closeModal } = useModal();

  return (
    <>
      <Modal isOpen={isOpen} closeModal={closeModal}>
        <CreateProject closeModal={closeModal} />
      </Modal>
      <Wrapper onClick={openModal}>
        <Add size={20} />
      </Wrapper>
    </>
  );
}

const Wrapper = styled.div`
  width: ${NAVLINK.WIDTH}px;
  height: ${NAVLINK.HEIGHT}px;
  border-radius: ${NAVLINK.BORDER_RADIUS}px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: ${({ theme }) => theme.transitionOption};
  background: ${({ theme }) => theme.transparentColor};
  color: ${({ theme }) => theme.background};

  :hover {
    cursor: pointer;
    background: ${({ theme }) => theme.color};
    border-radius: ${NAVLINK.HOVER_BORDER_RADIUS}px;
  }
`;

한 걸음 더!

Modal마다 서로 다른 variants를 적용하면 좋을 것 같다.
(variant: animation에 대한 설정을 담고있는 객체)

framer-motion의 variants를 DI 받는 구조로 구현해보자!

우선, variants와 type를 View 로직(함수 컴포넌트)로부터 분리하고 안정성을 부여하자.


1. variants 객체 상수화

우선 variants를 설정해준다.
허나, 저 모든 객체에 하나하나 Object.freeze()를 걸 엄두가 안난다. 저런 타입의 객체들은 Typescript의 Type 선언을 이용하여 Generic에 keyof를 할당하여 Readonly를 뿌려주는 것이 편하다.


2. type 선언

타입을 선언해주자.
제네릭으로 Readonly를 뿌려준다.


3. Modal의 variants DI 구현

항상 variants 객체를 DI해주는 것은 너무 귀찮다.
그렇기 때문에 default값을 할당해주고 값이 존재할 때만 적용되도록 구현했다.

import styled from 'styled-components';
import { ModalPortal } from 'components/modal';
import { AnimatePresence, motion } from 'framer-motion';
import { defaultVariants } from 'libs';

interface Variants {
  [key: string]: object;
}

interface ModalProps {
  children: React.ReactNode;
  isOpen: boolean;
  closeModal: () => void;
  variants?: Variants;
}

export function Modal({
  children,
  isOpen,
  closeModal,
  variants = defaultVariants,
}: ModalProps) {
  const closeHandler = (e: React.MouseEvent<HTMLElement>) => {
    e.stopPropagation();
    closeModal();
  };

  return (
    <ModalPortal>
      <AnimatePresence>
        {isOpen ? (
          <Overlay
            onClick={closeHandler}
            variants={defaultVariants}
            initial="from"
            animate="to"
            exit="exit"
          >
            <Wrapper variants={variants} onClick={(e) => e.stopPropagation()}>
              {children}
            </Wrapper>
          </Overlay>
        ) : null}
      </AnimatePresence>
    </ModalPortal>
  );
}

Modal 컴포넌트는 variants에 대한 의존성을 주입받는다.
variants 객체가 props로 주입되지 않을 경우, default값으로 overlay와 동일한 defualtVariants를 할당해주는 방식이다.


4. 적용

깔끔하고 아름다우며 좋은 코드인 것 같다! 🥳

오늘도 맛있는 고민 감사합니다.


겪었던 문제점

간단하게 보이지만 많은 시간과 고민이 들어간 코드이다.

1. Modal(View)은 useModal 내부? 외부?

처음엔 Modal 컴포넌트와 useModal이 분리되어있었다. Modal과 useModal을 각각 import하여 Modal 컴포넌트에 필요한 요소들을 useModal로부터 받아와 props로 전달해주는 패턴이었다.

View와 State를 분리하고자 설계를 했지만 두 가지 요소를 import하고 그 자체로 완결성을 갖지 못한다는 cost가 상대적으로 높다고 판단했다.

따라서 Modal이라는 기능을 가진 객체 내부에 modal에 대한 책임을 부여하는 것도 나쁘지 않다고 판단하여 useModal에서 Modal 컴포넌트를 import하여 함께 return하는 방식으로 전환하게 되었다.

2. motion-framer가 말썽이다.

Modal과 useModal을 분리하기 전, Modal 컴포넌트의 unMount motion이 잘 작동되고 있었다.
허나, useModal 내부로 Modal 컴포넌트를 이동시키는 로직을 적용하고 unMount animation이 적용되지 않는 것을 확인할 수 있었다.

import { useState } from 'react';
import { Modal } from 'components/modal';

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);
  function ModalComponent({ children }: ModalProps) {
    return (
      <Modal isOpen={isOpen} closeModal={closeModal}>
        {children}
      </Modal>
    );
  }

  return {
    Modal: ModalComponent,
    isOpen,
    openModal,
    closeModal,
  };
};

문제를 파악하기까지 꽤 오랜 시간이 걸렸다.

처음엔 ModalPortal의 문제일 것이라 생각하여 Portal 내부에 motion div를 선언하거나 stackoverflow의 여러 해결책을 적용해보는 듯 시간을 쏟게 되었다.

import { motion } from 'framer-motion';

export const ModalPortal = ({ children }: ModalPortalProps) => {
  const modalRoot = document.getElementById('modal-root') as HTMLElement;

  return ReactDom.createPortal(<motion.div>{children}<motion.div>, modalRoot);
};

분명 AnimatePresence에 대해선 잘 이해했다고 생각했는데, 기존의 지식만으로는 도저히 문제점을 파악할 수 없었다. stackoverflow에서도 해당 issue에 대해 대부분의 사람들이 이렇다할 해결책을 내지 못했다.

그러다 문득, 일전에 Router에 사용되는 값들을 하드코딩하기 싫어 상수로 선언한 뒤 컴포넌트를 Router내부에서 Auth 여부에따라 서로 다른 Route를 적용하며 배웠던 요소들이 생각났다.

일전에는 COMPONENT 필드가 Component를 return하는 메서드의 형태였다. 해당 과정에서 코드 및 타입을 작성하며 ReactNode와 JSX Element나 Component들의 특징 등, 시행착오에서 배웠던 것들이 생각나 바로 문제를 해결할 수 있었다.

// 변경된 로직. 받아온 그대로를 return한다.
import { useState } from 'react';
import { Modal } from 'components/modal';

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);

  return {
    Modal,
    isOpen,
    openModal,
    closeModal,
  };
};

고민하고 공부할 것이 많아서 행복하다.

2개의 댓글

comment-user-thumbnail
2023년 8월 17일

좋은 정보 공유 감사합니다 :)

1개의 답글