프로젝트를 하면서 새로고침 & 창닫기, 뒤로가기, 페이지 이동에 대한 핸들링을 해야했고 작업하면서 학습한 내용에 대해 정리해보려고 한다.
새로고침 & 창닫기 이벤트의 경우 "beforeunload" 이벤트를 읽어 event.preventDefault() 설정을 해주면 된다.
useEffect(() => {
const handleUnload = (e: BeforeUnloadEvent) => {
// form 입력 중일 때만, 그러나 submit 버튼을 누르면 이동을 하기 때문에 submit은 제외하고
if(isDirty && !isSubmit) {
e.returnValue = "";
e.preventDefault();
}
}
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
}, [isDirty, isSubmit])
뒤로가기 이벤트의 경우 처음 페이지에 진입했을 때, 현재 페이지를 history API에 등록해주고 "popstate" 이벤트가 발생했을 때 모달이 열리게 해서 취소와 확인에 따라 핸들링을 해준다.
🥸 내가 이해하기로는 아래와 같이 동작하는 것 같다.
일반 history: [현재 url, 이전 url] -> 뒤로가기 클릭 시 [이전 url]
history.pushState로 등록했을 때: [현재 url, 현재 url, 이전 url] -> 뒤로가기 클릭 시 [현재 url, 이전 url]
const [isFirstClicked, setIsFirstClicked] = useState(false);
// strict 모드일 경우, 두번 로드되어서 처음 로드되었을 때에만 등록한다.
useEffect(() => {
if(!isFirstClicked) {
setIsFirstClicked(true);
// history에 현재 페이지 등록
history.pushState(null, "", location.href);
}
}, [isFirstClicked]);
useEffect(() => {
const handlePopState = () => {
if(isDirty && !isSubmit) {
setIsOpen(true);
}
}
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
}
}, [isDirty, isSubmit]);
// 모달에서 "확인" 버튼 클릭했을 때 -> history.back()으로 뒤로가기
const handleConfirmNavigation = () => {
if(pendingNavigation) {
... // navigating
} else {
history.back();
}
setIsOpen(false);
}
페이지 이동은 router 동작 시 원래의 이동 경로와 옵션들(router 동작 시 필요한 속성들)을 저장해두었다가 모달을 통해 동작을 원한다면 그때 작동시키도록 한다.
const router = useRouter();
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const confirmNavigation = (
originalFunction: (href: string, options?: {scroll?: boolean}) => void,
href: string,
options?: {scroll?: boolean}
) => {
if (isDirty && !isSubmit) {
// 클로저를 통해 pendingNavigation에 () => originalFunction(href, options) 자체를 set하도록 설정
setPendingNavigation(() => () => originalFunction(href, options));
setIsOpen(true);
return;
}
originalFunction(href, options);
};
const originalPush = router.push;
const originalReplace = router.replace;
// router에 덮어씌우기
router.push = (href: string, options?: {scroll?: boolean}) => confirmNavigation(originalPush, href, options);
router.replace = (href: string, options?: {scroll?: boolean}) => confirmNavigation(originalReplace, href, options);
return () => {
router.push = originalPush;
router.replace = originalReplace;
}
}, [isDirty, isSubmit, router]);
// 모달에서 확인 버튼 눌렀을 때
const handleConfirmNavigation = () => {
// pendingNavigation에 등록된 router함수가 있다면
if(pendingNavigation) {
// 등록된 함수를 실행시키고 다시 null로 set
pendingNavigation();
setPendingNavigation(null);
} else {
// 없다면 뒤로가기를 막은 것이기 때문에 뒤로가기!
history.back();
}
setIsOpen(false);
}
//escape-modal.tsx
import AlertModal from "@/components/common/modal";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
interface Props {
isDirty: boolean;
isSubmit: boolean;
}
export default function EscapeModal({ isDirty, isSubmit }: Props) {
const router = useRouter();
const [pendingNavigation, setPendingNavigation] = useState<
(() => void) | null
>(null);
const [isOpen, setIsOpen] = useState(false);
const [isFirstClicked, setIsFirstClicked] = useState(false);
useEffect(() => {
if (!isFirstClicked) {
setIsFirstClicked(true);
history.pushState(null, "", location.href);
}
}, [isFirstClicked]);
useEffect(() => {
const handleUnload = (e: BeforeUnloadEvent) => {
if (isDirty && !isSubmit) {
e.returnValue = true;
e.preventDefault();
}
};
const handlePopState = () => {
if (isDirty && !isSubmit) {
setIsOpen(true);
}
};
const confirmNavigation = (
originalFunction: (href: string, options?: { scroll?: boolean }) => void,
href: string,
options?: { scroll?: boolean },
) => {
if (isDirty && !isSubmit) {
setPendingNavigation(() => () => originalFunction(href, options));
setIsOpen(true);
return;
}
originalFunction(href, options);
};
const originalPush = router.push;
const originalReplace = router.replace;
router.push = (href: string, options?: { scroll?: boolean }) =>
confirmNavigation(originalPush, href, options);
router.replace = (href: string, options?: { scroll?: boolean }) =>
confirmNavigation(originalReplace, href, options);
window.addEventListener("beforeunload", handleUnload);
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("beforeunload", handleUnload);
window.removeEventListener("popstate", handlePopState);
router.push = originalPush;
router.replace = originalReplace;
};
}, [isDirty, isSubmit, router]);
const handleConfirmNavigation = () => {
if (pendingNavigation) {
pendingNavigation();
setPendingNavigation(null);
} else {
history.back();
}
setIsOpen(false);
};
return (
<AlertModal
title="페이지를 떠나시겠습니까?"
description="변경사항이 저장되지 않을 수 있습니다."
onClick={handleConfirmNavigation}
isOpen={isOpen}
setIsOpen={setIsOpen}
iconType="Warning"
/>
);
}