기록을 주제로 한 프로젝트 진행하면서, 글 작성 페이지에서 '이탈 방지 모달'이 필요했다. 추가적으로 해당 모달이 디자인되어 있었기 때문에 '커스텀 팝업 창'을 띄우는 방법도 고민하게 되었다
뒤로 가기는 popstate 이벤트를 발생시킨다.
popstate 이벤트는 사용자의 기록(history) 항목이 변경될 때마다 발생한다.
const handlePopState = useCallback(async () => {
// 페이지를 벗어나지 않아야 하는 경우
if (isPreventCondition && !(await getPageMoveState())) {
history.pushState(null, '', '');
return;
}
history.back();
}, [isPreventCondition]);
useEffect(() => {
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [handlePopState]);
외부 링크 이동은 새로고침, 창 닫기, a 태그 이동, url 주소 변경을 할 때 발생하고 beforeunload 이벤트를 통해 감지할 수 있다.
이 이벤트가 발생하면 Chrome에서 제공하는 confirm 창을 확인할 수 있다.
const handleBeforeUnload = useCallback(
(e: BeforeUnloadEvent) => {
// 페이지를 벗어나지 않아야 하는 경우
if (isPreventCondition) {
e.preventDefault();
e.returnValue = true; // legacy 브라우저를 위해 추가한다.
}
},
[isPreventCondition],
);
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [handleBeforeUnload]);
내부 링크 이동은 nextJS의 router를 사용하는 Link 태그를 클릭할 때 발생한다.
이에 따라 router.push 함수를 커스텀해준다.
기존의 router.push 메서드는 originPush 변수에 남겨두고, 조건에 따라 originPush를 실행하는 newPush 함수를 만들어서 router.push 함수에 적용해준다.
const router = useRouter();
useEffect(() => {
const originalPush = router.push;
const newPush = async (href: string, options?: NavigateOptions | undefined) => {
// 페이지를 벗어나지 않아야 하는 경우
if (isPreventCondition && !(await getPageMoveState())) {
return;
}
originalPush(href, options);
return;
};
router.push = newPush;
return () => {
router.push = originalPush;
};
}, [router, isPreventCondition]);
위의 세가지 경우의 코드에서 페이지를 벗어나지 않아야 하는 경우인 조건문에서 나왔던 isPreventCondition는 부모 컴포넌트에서 props로 전달했다.
getPageMoveState는 커스텀 팝업의 결과로 boolean 타입을 반환한다.
openModalAndWaitForChoice 함수에서setMoveResolveFn에 resolve 함수를 설정한다. const [moveResolveFn, setMoveResolveFn] = useState<((choice: boolean) => void) | null>(null);
const openModalAndWaitForChoice = () => {
return new Promise<boolean>(resolve => {
setModalOpen(true);
setMoveResolveFn(() => resolve);
});
};
const getPageMoveState = async () => {
return await openModalAndWaitForChoice();
};
// 모달 확인 버튼
const handleMoveModal = () => {
if (moveResolveFn) {
setModalOpen(false);
moveResolveFn(true);
}
};
// 모달 취소 버튼
const handleNotMoveModal = () => {
if (moveResolveFn) {
setModalOpen(false);
moveResolveFn(false);
}
};
type StopMovePageProps = {
isPreventCondition: boolean;
isPageMove: () => Promise<boolean>;
};
const StopMovePage = ({ isPreventCondition, isPageMove }) =>{
const router = useRouter();
// 뒤로 가기
const handlePopState = useCallback(async () => {
// 페이지를 벗어나지 않아야 하는 경우
if (isPreventCondition && !(await isPageMove())) {
history.pushState(null, '', '');
return;
}
history.back();
}, [isPreventCondition]);
useEffect(() => {
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [handlePopState]);
// 외부 링크 이동
const handleBeforeUnload = useCallback(
(e: BeforeUnloadEvent) => {
// 페이지를 벗어나지 않아야 하는 경우
if (isPreventCondition) {
e.preventDefault();
e.returnValue = true; // legacy 브라우저를 위해 추가한다.
}
},
[isPreventCondition],
);
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [handleBeforeUnload]);
// 내부 링크 이동
useEffect(() => {
const originalPush = router.push;
const newPush = async (href: string, options?: NavigateOptions | undefined) => {
// 페이지를 벗어나지 않아야 하는 경우
if (isPreventCondition && !(await isPageMove())) {
return;
}
originalPush(href, options);
return;
};
router.push = newPush;
return () => {
router.push = originalPush;
};
}, [router, isPreventCondition]);
}
const PageView = () => {
const [moveResolveFn, setMoveResolveFn] = useState<((choice: boolean) => void) | null>(null);
const openModalAndWaitForChoice = () => {
return new Promise<boolean>(resolve => {
setModalOpen(true);
setMoveResolveFn(() => resolve);
});
};
const getPageMoveState = async () => {
return await openModalAndWaitForChoice();
};
// 모달 확인 버튼
const handleMoveModal = () => {
if (moveResolveFn) {
setModalOpen(false);
moveResolveFn(true);
}
};
// 모달 취소 버튼
const handleNotMoveModal = () => {
if (moveResolveFn) {
setModalOpen(false);
moveResolveFn(false);
}
};
return (
<>
<StopMovePage
isPreventCondition={페이지 이동 조건}
isPageMove={handleOpenStopModal}
/>
<AlertDialog open={modalOpen}>
...
<AlertDialogCancel onClick={handleNotMoveModal}>취소</AlertDialogCancel>
<AlertDialogAction onClick={handleMoveModal}>확인</AlertDialogAction>
</AlertDialog>
</>
)
}
