재사용 가능한 Modal 만들기
서론
사이트에서 별도의 알림을 보여줄 때 Modal을 통해 정보를 제공해주기로 결정했다.
팀원과 이야기를 통해 여러곳에서 재사용 될 모달 요소를 추상화하여 사용하기로 결정했고, 해당 기능을 내가 개발하기로 했다.
최소의 사이드 이팩트
모달은 언제나 화면 최 상단에 떠있어야 하기 때문에
다른 스타일의 영향을 최소한으로 받아야 한다.
이를 createPortal 없이 컴포넌트 어딘가(?) 에서 랜더링한다면 부모의 css에 영향을 받아 이상하게 랜더링이 될 가능성이 있었다.
이때문에 createPortal을 사용해 모달을 별도 modal-root의 자식요소로 랜더링하기로 해 모달이 랜더링될 수 있는 포탈을 만들어주었다.
import { PropsWithChildren } from 'react'; import ReactDom from 'react-dom'; export default function ModalPortal({ children }: PropsWithChildren) { const modalRoot = document.getElementById('modal-root') as HTMLElement; return ReactDom.createPortal(children, modalRoot); }
모달 코드 만들기
import { PropsWithChildren } from 'react'; import ModalPortal from '@/ModalPortal'; import Overlay from '@/common/layout/Overlay'; interface ModalProps { isShow: boolean; } export default function Modal({ isShow, children, }: PropsWithChildren<ModalProps>) { return ( <ModalPortal> {isShow && ( <Overlay overlayStyle={{ $backgroundColor: 'rgba(0, 0, 0, 0.4)', $mixBlendMode: 'normal', }} > {children} </Overlay> )} </ModalPortal> ); }
모달을 제어할 상태를 내부에서 관리하게되면 재사용성이 떨어지기 때문에 해당 외부에서 props로 받는형식으로 구현했으며, 기본적으로 children타입을 제공하는 PropsWithChildren을 사용해서 타입을 좁혔다.
트러블 슈팅
useEffect(() => { const scrollbarWidth = window.innerWidth - document.documentElement.offsetWidth; document.body.style.paddingRight = `${scrollbarWidth}px`; document.body.style.overflow = 'hidden'; (document.activeElement as HTMLElement).blur(); return () => { document.body.style.overflow = 'auto'; document.body.style.paddingRight = `0px`; }; }, []);
모달이 뜰 때 공통적인 요구사항으로는 우측에 스크롤이 없어지는 것 이였는데 이를 useEffect를 통해서 컴포넌트가 랜더링될때 스크롤을 없애고 우측 스크롤 여백만큼 padding을 주어 화면이 움찔 하는 현상을 막는 코드를 작성해주었다.
최초에는 위 useEffect가 Modal컴포넌트 내부에 있었는데 모달을 닫아도 스크롤이 복원되는 클린업이 작동하지 않는 문제가 발생했다.
이유는 우리는 모달을 닫아 언마운트되겠다고 생각했지만 실제로 언마운트되는것은
{isShow && (children)}
모달 내부에 있는 children이 언마운트 되는것이기 때문에 당연하게도 클린업이 작동하지 않았다.
그렇기 때문에
import { OverlayDiv } from '@/common/layout/styles'; import { OverlayDivProps } from '@/common/types'; import { PropsWithChildren, useEffect } from 'react'; export default function Overlay({ children, overlayStyle, }: PropsWithChildren<{ overlayStyle: OverlayDivProps; }>) { useEffect(() => { const scrollbarWidth = window.innerWidth - document.documentElement.offsetWidth; document.body.style.paddingRight = `${scrollbarWidth}px`; document.body.style.overflow = 'hidden'; (document.activeElement as HTMLElement).blur(); return () => { document.body.style.overflow = 'auto'; document.body.style.paddingRight = `0px`; }; }, []); return ( <OverlayDiv $backgroundColor={overlayStyle.$backgroundColor} $mixBlendMode={overlayStyle.$mixBlendMode} > {children} </OverlayDiv> ); }
하위의 오버레이용 컴포넌트에서 오버레이가 발생 시 스크롤을 제거할 수 있도록 추가했다.
모달 외에도 추후 튜토리얼 UI를 구현하기 위해 오버레이 위에 UI를 띄워야 해서 해당 컴포넌트로 분리를 진행했다.
결과
약 20개 이상의 파일에서 공용으로 사용하는 Modal 컴포넌트를 개발하였으며 createPortal을 통해 스타일의 영향을 최소화하고 내부 모달은 children을 통해 개발자가 마음대로 커스텀이 가능하도록 구현했다.
간단한 컴포넌트지만 사용을 하며 해당 컴포넌트의 버그도 생기고, 요구사항도 변경되며 여려 생각을 하게 되는 시간이였던 것 같다.