Next.js App Router에서 페이지 이동 막기 + 커스텀 팝업창 띄우기

euNung·2024년 6월 25일

Next.js

목록 보기
1/5

기록을 주제로 한 프로젝트 진행하면서, 글 작성 페이지에서 '이탈 방지 모달'이 필요했다. 추가적으로 해당 모달이 디자인되어 있었기 때문에 '커스텀 팝업 창'을 띄우는 방법도 고민하게 되었다

페이지 이탈 방지 구현

뒤로 가기

뒤로 가기는 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 타입을 반환한다.

  1. openModalAndWaitForChoice 함수에서
    modal을 open하도록 하고, setMoveResolveFn에 resolve 함수를 설정한다.
  2. 모달 확인 버튼을 누르면 true, 모달 취소 버튼을 누르면 false 값을 반환하도록 설정한다.
  3. 페이지 이동을 막는 컴포넌트에 getPageMoveState를 전달하여 사용자 선택에 따른 값을 전달하도록 한다.
  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>
        </>
      )
  }

구현 화면

구현 화면

참고

Next.js App Router 페이지 이동 막기

profile
프론트엔드 개발자

0개의 댓글