Overlay 의 재설계
<aside> 💪
화면을 띄우는 모달 방식을 검토해 보자
</aside>
tooltip, modal 등 화면 위에서 보여지는 것들의 구현 방식이 일관되지 않아 프로젝트 내 일관성, 유지보수성을 고려해 오버레이를 재설계함.
[ 적용 근거 ]
모달이 쓰이는 모든 컴포넌트에 중복 선언 중인 [isOpen, setIsOpen] 제거 가능
선언적으로 오버레이 관리 가능
상태 및 이벤트 핸들링을 컴포넌트 외부로 캡슐화 → UI와 비즈니스 로직 분리 효과
외부 클릭 감지(useClickOutside) 자동 제공
재사용성 강화
→ 현재도 ModalWrapper 등으로 재사용 중이지만, UI & 동작의 명확한 분리가 더 쉬워질 것으로 기대됨
[ 적용 결과 ]
라이브러리 설치
pnpm install overlay-kit
isModalOpen 이 사용되는 페이지
const ModalWrapper = forwardRef<ModalWrapperRef, ModalWrapperProps>(function Modal(
{ children, backdrop = false },
ref,
) {
// 오버레이의 열림/닫힘 상태를 관리하는 state
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const dialog = useRef<HTMLDialogElement>(null);
// 오버레이를 여닫는 함수
useImperativeHandle(ref, () => ({
open() {
dialog.current?.showModal();
setIsModalOpen(true);
},
close() {
dialog.current?.close();
setIsModalOpen(false);
},
}));
const modalElement = document.getElementById('modal');
// 오버레이 외부 영역 클릭 시의 동작
const handleClick = (e: MouseEvent<HTMLDialogElement>) => {
if (e.target === dialog.current) {
dialog.current?.close();
setIsModalOpen(false);
}
};
if (!modalElement) {
return null;
}
// 오보레이 열렸을 때만 콘텐츠 표시되도록 설정
const content =
typeof children === 'function'
? (children as (props: { isModalOpen: boolean }) => ReactNode)({ isModalOpen })
: children;
return createPortal(
<dialog
ref={dialog}
onClick={handleClick}
className={`custom-dialog bg-transparent ${backdrop ? 'with-backdrop' : ''}`}
>
{content}
</dialog>,
modalElement,
);
});
export default ModalWrapper;
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<div style={{ fontSize: '16px' }}>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<OverlayProvider>
<Provider>
<RouterProvider router={router} />
</Provider>
</OverlayProvider>
</QueryClientProvider>
);
};
OverlayProvider는 overlay-kit의 핵심 상태 관리 컨텍스트를 제공하여, 앱 전역에서 모달과 오버레이 컴포넌트의 열림/닫힘 상태를 일관되게 관리하기 위해 추가했습니다. 이를 통해 여러 곳에서 호출되는 모달이 충돌 없이 정상적으로 작동하도록 돕습니다.
import { overlay } from 'overlay-kit';
import { ReactNode } from 'react';
interface OverlayProps {
backdrop?: boolean;
content: (props: { isOpen: boolean; close: () => void }) => ReactNode;
}
// Overlay 컴포넌트 구현
export const Overlay = ({ content, backdrop = true }: OverlayProps) => {
// overlay.open: 모달 열기 함수 (overlay-kit 제공)
overlay.open(({ isOpen, close }) =>
isOpen ? (
<div
className={`fixed inset-0 z-50 flex items-center justify-center ${
backdrop ? 'bg-[rgba(0,0,0,0.7)]' : 'bg-transparent'
}`}
// 바깥 영역 클릭 시 모달 닫기
onClick={close}
>
{/* 모달 내용 컨테이너, 내부 클릭 시 바깥(onClick)으로 이벤트 전파되지 않도록 차단 */}
<div onClick={(e) => e.stopPropagation()}>
{/* 사용자에게 전달된 content 함수 실행, isOpen/close 전달 */}
{content({ isOpen, close })}
</div>
</div>
) : null, // 모달이 닫혔을 경우 렌더링하지 않음
);
};
Overlay({
backdrop: false, // 배경을 투명하게 표시하고 싶을 때
content: ({ isOpen, close }) => ( <~~Modal isOpen={isOpen} isClose={close} />,) // isOpen, close는 필요한 값만 사용
});
(기존)
const handleCloseDeleteAccountModal = () => {
deleteAccountModalRef.current?.close();
};
const handleOpenDeleteAccountModal = () => {
deleteAccountModalRef.current?.open();
};
<ModalWrapper ref={deleteAccountModalRef} backdrop>
{(_) => (
<ModalContentsAlert.DeleteAccount
onConfirm={handleDeleteAccount}
onCloseModal={handleCloseDeleteAccountModal}
userEmail={props.email}
/>
)}
</ModalWrapper>
(교체)
const handleLogoutModal = () => {
Overlay({
backdrop: true,
content: ({ close }) => (
<ModalContentsAlert.Logout onConfirm={reloginWithoutLogout} onCloseModal={close} userEmail={props.email} />
),
});
};