핏메이트를 리팩토링하면서 모달을 갈아엎어봤다..!
// 모달을 사용하는 페이지
const [isCancleModal, setIsCancleModal] = useState(false);
// ...
{isCancleModal && <LoginModal setIsCancleModal={setIsCancleModal} />}
// 모달
const CancleModal = ({ setIsCancleModal }) => {
const navigate = useNavigate();
const handleCancle = (e) => {
setIsCancleModal(false);
navigate("/");
};
const handleLeave = (e) => {
setIsCancleModal(false);
};
return (
<S.ModalBox>
<S.ModalWrapper>
<span className="leaveModalTitle">
작성 중인 회원가입 내용이 저장되지 않습니다
<br />
나가시겠습니까?
</span>
<div className="leaveModalButtonWrapper">
<button className="cancleBtn" onClick={handleCancle}>
네 나갈래요
</button>
<button className="leaveBtn" onClick={handleLeave}>
아니요
</button>
</div>
</S.ModalWrapper>
</S.ModalBox>
);
};
기존에는 모달을 사용하는 페이지에서 모달에 관한 상태를 useState로 두고 이를 버튼을 통해 state를 핸들링하며 이에 따라 모달을 띄우고 있었다.
상태로 관리하다보니 상태에따라 모달을 띄우면 되니 편리하다는 점도 있지만, 다음과 같은 문제가 있다.
DOM상의 모달의 위치
선언적이지 않음
페이지마다 상태를 관리해서 여러 모달 관리 해야하는 경우 복잡성 증가 및 불필요한 렌더링 문제
합성컴포넌트를 이용해 Headless한 모달을 만들어 해결하였다.

현재 서비스에서는 위와 같이 다양한 모달이 존재한다. 이를 사용하는곳에서 유연하게 다룰 수 있도록 합성컴포넌트로 구현해보자.
const ModalMain = ({
children,
isCloseButton = false,
}: PropsWithChildren<ModalMainProps>) => {
return createPortal(
<BackOverlay>
<ModalWrapper $isCloseButton={isCloseButton}>
{isCloseButton && (
<IconButton
icon="CloseBold"
style={{ position: "absolute", top: "24px", right: "24px" }}
/>
)}
{children}
</ModalWrapper>
</BackOverlay>,
document.body,
)
}
const Modal = Object.assign(ModalMain, {
Content: ModalContent,
Title: ModalTitle,
Footer: ModalFooter,
})
// Title
const ModalTitle = ({ children }: PropsWithChildren) => {
return <>{children}</>
}
export default ModalTitle
// Content
const ModalContent = ({ children }: PropsWithChildren) => {
return <div>{children}</div>
}
export default ModalContent
`
// Footer
const FooterWrapper = styled.div``
const ModalFooter = ({ children }: PropsWithChildren) => {
return <FooterWrapper>{children}</FooterWrapper>
}
export default ModalFooter
자세히 살펴보자.
const Modal = Object.assign(ModalMain, {
Content: ModalContent,
Title: ModalTitle,
Footer: ModalFooter,
})
우선 Modal을 크게 Title(제목), Content(내용), Footer(버튼들)로 나눴다. 이렇게 대응한다면 서비스에서 사용하는 모달들을 모두 표현할 수 있다.
return createPortal(
<BackOverlay>
<ModalWrapper $isCloseButton={isCloseButton}>
{isCloseButton && (
<IconButton
icon="CloseBold"
style={{ position: "absolute", top: "24px", right: "24px" }}
/>
)}
{children}
</ModalWrapper>
</BackOverlay>,
document.body,
)
모달은 의미상 항상 DOM요소에 최상위에 있어야하므로 createPortal를 사용하였고, 그외에 기본적인 기능들을 추가해줬다.(배경오버레이 등)
추가적으로, 닫기버튼이 있는 경우도 있고 없는 경우도 있어서 이를 boolean 형태의 props로 넘겨서 동적으로 렌더링할 수 있도록 했다.
const handleWrapperClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
// 닫는 로직
}
}
return createPortal(
<BackOverlay onClick={handleWrapperClick}>
// ...
</BackOverlay>,
document.body,
)
또한, Overlay(바깥 영역)을 클릭하면 모달이 닫힐 수 있도록 해줬다.
자 이제 모달 UI는 간편하게 조합이 가능하다. 좀더 직관적으로 구조가 예측이 가능하다. 다음과 같이 사용하면 된다.
// 알림 모달
import Button from "@components/Button/Button"
import Modal from "@components/Modal/Modal"
import Title from "@components/Title/Title"
const AlertModal = () => {
return (
<Modal>
<Modal.Title>
<Title variant="midA">
현재 수정중인 페이지에요
<Title.SubBottomTitle>
10월 말에 출시될 예정이에요!
</Title.SubBottomTitle>
</Title>
<Modal.Footer>
<Button
variant="main">
확인
</Button>
</Modal.Footer>
</Modal.Title>
</Modal>
)
}
export default AlertModal
모달은 전체 페이지에서 다양하게 쓰이기도하고 페이지마다 모달 상태에 따라 관리하는것은 관리도어렵고 선언적이지가 않아 전역적으로 상태를 관리하기로 했다.
// useModalStore
import { create } from "zustand"
export interface ModalStoreProps {
modalState: Record<string, boolean>
setModalState: (newState: Record<string, boolean>) => void
}
export const useModalStore = create<ModalStoreProps>((set) => ({
modalState: {
알림: false,
나가기: false,
로딩: false,
삭제: false,
},
setModalState: (newState) =>
set((state) => ({
modalState: {
...state.modalState,
...newState,
},
})),
}))
// useModal
import { useEffect } from "react"
import { useModalStore } from "@store/useModalStore"
interface ModalOptions {
beforeClose?: () => void | Promise<void>
afterClose?: () => void | Promise<void>
}
export const useModal = (modalName: string, options: ModalOptions = {}) => {
const { modalState, setModalState } = useModalStore()
const { beforeClose, afterClose } = options
const isOpen = modalState[modalName]
const onOpen = () => {
setModalState({ [modalName]: true })
}
const onClose = async () => {
await beforeClose?.()
setModalState({ [modalName]: false })
await afterClose?.()
}
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = "auto"
}
}, [isOpen])
return { isOpen, onOpen, onClose }
}
useModalStore를 통해 전역적으로 여러 모달을 관리한다. 그리고 이렇게 만든 전역 모달 상태를 모달 이름을 통해 간편하게 가져올 수 있도록 useModal 훅을 구현했다.
추가적으로 구현한 옵션이 존재한다.
1. 모달이 띄워졌을때 인터랙션등(스크롤)을 막고 싶은 경우를 대비했다.
2. 모달이 닫히기전과 닫힌후의 동작이 필요할 경우를 대비했다.
import Button from "@components/Button/Button"
import Modal from "@components/Modal/Modal"
import Title from "@components/Title/Title"
import { useModal } from "@hooks/useModal"
const LoadingModal = () => {
const { isOpen, onClose } = useModal("로딩")
return (
<Modal
isOpen={isOpen}
onClose={onClose}>
<Modal.Title>
<Title variant="midA">
추천 시간이 <br />
예상보다 길어지고 있어요
<Title.SubBottomTitle>
잠시 후 다시 시도해 주세요
</Title.SubBottomTitle>
</Title>
</Modal.Title>
<Modal.Footer>
<Button
variant="main"
size="lg"
onClick={onClose}>
확인
</Button>
</Modal.Footer>
</Modal>
)
}
export default LoadingModal
사용하는 곳에서는 위와같이 모달을 Trigger하도록 구현했다.
import { Meta, StoryObj } from "@storybook/react"
import Button from "@components/Button/Button"
import Modal from "@components/Modal/Modal"
import AlertModal from "@components/Modal/components/Alert/AlertModal"
import DeleteModal from "@components/Modal/components/Delete/DeleteModal"
import LoadingModal from "@components/Modal/components/Loading/LoadingModal"
import QuitModal from "@components/Modal/components/Quit/QuitModal"
import { useModal } from "@hooks/useModal"
const meta: Meta<typeof Modal> = {
component: Modal,
title: "components/Modal",
tags: ["autodocs"],
parameters: { layout: "centered" },
}
export default meta
type Story = StoryObj<typeof Modal>
interface TriggerProps {
name: string
}
const Trigger = ({ name }: TriggerProps) => {
const { onOpen } = useModal(name)
return (
<Button
onClick={onOpen}
variant="main">
{name}
</Button>
)
}
export const Alert: Story = {
render: () => (
<>
<Trigger name={"알림"} />
<AlertModal />
</>
),
}
export const Loading: Story = {
render: () => (
<>
<Trigger name={"로딩"} />
<LoadingModal />
</>
),
}
export const Quit: Story = {
render: () => (
<>
<Trigger name={"나가기"} />
<QuitModal />
</>
),
}
export const Delete: Story = {
render: () => (
<>
<Trigger name={"삭제"} />
<DeleteModal bodyPart={"가슴"} />
</>
),
}

참고로 스토리북에서는 위와 같이 Trigger 버튼을 재사용해 UI를 테스트할 수 있도록 했다.
현재 모달을 전역 스토어에서 관리하고 있다.
하지만, 서비스 구조가 복잡해지고 거대해질 경우 모달을 사용하는곳과 전역 스토어와의 물리적 거리가 멀어져 유지보수 하기가 힘들것이다. 어떻게 하면 좋을까?
https://velog.io/@yonghyeun/리액트에서-모달과-같은-오버레이를-하나의-커스텀-훅으로-관리해보자
https://velog.io/@yoonkeee/toss-order-2
https://velog.io/@doeunnkimm_/Modal-컴포넌트-모듈화라고-쓰고-선언적인-코드라고-읽어보기