2dowon blog logo

2dowon

Next.js App Router에서 페이지 이동 감지하기

June 16, 2024

해당 글은 Next.js App Router(14버전)에서 페이지 이동을 감지하는 법에 대한 글입니다.
만약 Next.js Page Router (12버전 이하)를 사용 중이라면 해당 글에서는 관련 내용을 다루지 않습니다!

회사에서 쇼핑리스트 기능을 만들면서 아래와 같은 상황이 생겼다.

쇼핑리스트를 수정하고 있을 때 저장 버튼을 누르지 않은 상태라면 페이지 이동할 때 경고 모달을 노출하도록 해주세요~

처음에는 '넵 알겠습니다~'라고 대답하면서 크게 어렵지 않다 생각했었다. 브라우저 뒤로가기 하는 경우나 브라우저를 새로고침하거나 브라우저를 닫는 경우는 web api가 존재하고 Next.js Page Router를 사용하는 기존 서비스를 구현할 때 router가 변하는 것에 대해서 구현해본 적이 있었기 때문이다.
다만 실제로 구현을 해보니 Next.js App Router에서는 Page Router와 다르게 useRouter에 events가 없기 때문에 생각보다 router가 변하는 것을 감지하는 작업이 꽤 까다로웠고 정보를 찾기도 쉽지가 않았어서 기록해두려고 한다!

1. web api를 이용해 브라우저 뒤로가기, 새로고침, 창 닫기 핸들하기

예상대로 브라우저 뒤로가기는 popstate, 브라우저 창 새로고침과 브라우저 창 닫기는 berforeunload web api를 이용하면 된다.

브라우저 창 새로고침 & 브라우저 창 닫기

beforeunload event를 이용하면 유저가 페이지를 떠나려고 할 때 실제로 페이지를 떠날 것인지를 확인하는 브라우저의 기본 대화상자를 표시할 수 있다.

import { useEffect } from "react";
 
const usePreventBeforeUnloadLeave = () => {
	const handleBeforeunload = (e: BeforeUnloadEvent) => {
		e.preventDefault();
		return "";
	};
 
  
	useEffect(() => {
		window.addEventListener("beforeunload", handleBeforeunload);
		return () => {
			window.removeEventListener("beforeunload", handleBeforeunload);
		};
	}, []);
};
 
export default usePreventBeforeUnloadLeave;

브라우저 뒤로 가기

popstate event를 이용하면 유저의 뒤로 가기, 앞으로 가기 액션을 탐지할 수 있다.
나는 유저가 뒤로 가기 액션을 하려고 할 때 경고 모달을 띄우려고 하고, 유저는 경고 모달을 통해서 다음과 같이 2가지 액션을 할 수 있다.

하지만 handlePopstate핸들러가 실행되어도 결국 popstate 뒤로가기 액션은 발생한다. 따라서 당장은 바로 뒤로가지 않도록 처리하기 위해서 useEffect 에서 현재 경로를 window.history에 추가해준다. 이 부분은 딱 1번만 실행되면 되기 때문에 useRef를 이용해 한 번만 실행되는 것을 보장하도록 했다. 이 과정을 통해서 유저가 뒤로가기를 하더라도 실제로는 페이지가 뒤로가지 않으면서 경고 모달을 노출할 수 있게 된다.

그리고 '모달 닫기'를 통해 현재 페이지에 남아있는 경우에는 다시 뒤로가기를 할 수 있는 상황이기 때문에 handlePopStateDialogClose 처럼 다시 한 번 현재 경로를 window.history에 추가하고, '모달 나가기'를 통해 현재 페이지를 벗어나는 경우에는 window.history.back()을 실행해서 실제로 뒤로가기를 실행하면 된다.

import { useEffect, useRef, useState } from "react";
 
const usePreventPopStateLeave = () => {
	const isFirstPopState = useRef(false);
	const [showPopStateDialog, setShowPopStateDialog] = useState(false);
	
	const handlePopstate = () => setShowPopStateDialog(true);
	
	const handlePopStateDialogLeave = () => {
		setShowPopStateDialog(false);
		window.history.back();
	};
	
	const handlePopStateDialogClose = () => {
		window.history.pushState(null, "", window.location.href);
		setShowPopStateDialog(false);
	};
	
	useEffect(() => {
		if (!isFirstPopState.current) {
			window.history.pushState(null, "", window.location.href);
			isFirstPopState.current = true;
		}
	}, []);
	
	useEffect(() => {
		window.addEventListener("popstate", handlePopstate);
		return () => {
			window.removeEventListener("popstate", handlePopstate);
		};
	}, []);
 
	return {
		showPopStateDialog,
		setShowPopStateDialog,
		handlePopStateDialogLeave,
		handlePopStateDialogClose,
	};
};
 
export default usePreventPopStateLeave;

2. 페이지 이동 감지하기

시도 1. nextjs-router-events 라이브러리 사용

Next.js App Router에서 페이지가 이동했는지를 구현하는 것이 꽤 까다로움을 인지하고 발견한 것이 바로 nextjs-router-events 이 라이브러리 였다. 해당 라이브러리는 Next.js Page Router에서 router가 변하는 것을 감지할 때 사용하는 router-events가 Next.js App Router에서는 더 이상 지원되지 않으면서 이를 Next.js App Router에서도 사용할 수 있도록 만들어진 것으로 나에게 딱 맞는다고 생각해서 바로 도입해보게 되었다.

도입 직후 처음에는 적용해보고 '오 잘되네?'라고 생각했는데... ㅎㅎ
해당 라이브러리에서는 아래 이미지처럼 _blank를 통해 새 창으로 여는 경우에도 네비게이션 이동으로 감지하는 이슈가 존재했다.

detect-page-navigation-in-next-app-router.png

내가 현재 만들고 있는 쇼핑리스트 기능에서는 수정하면서 현재 페이지를 벗어나는 것은 안되지만, 쇼핑리스트에 추가한 제품들을 클릭했을 때는 해당 제품들의 상세 페이지나 구매 페이지 등을 새 창으로 열어서 보는 것은 가능하기 때문이다. 즉, 새 창으로 열리는 것은 내가 현재 보고 있는 페이지에서 벗어난 것은 아니기 때문에 나는 페이지가 이동되었다고 보지 않는데, 이 라이브러리에서는 페이지가 이동되었다고 보는 것이었다. 이 외에도 특정 경우에는 페이지 이탈이 되어야 하는 경우에도 페이지 이탈이 안되는 경우도 있었다...ㅎ
이 라이브러리를 고쳐서 사용하는 방향도 잠깐 생각해봤는데, 해당 기능을 구현할 수 있는 시간이 많지 않아서 다른 방법을 찾아보기로 했다.

시도 2. router.push를 newPush로 재정의

결국 페이지의 이동이 Next에서는 router.push 를 통해 발생하기 때문에 allowRouteChange prop이 false로 페이지를 벗어나는 것을 허용하지 않는 경우에는 해당 동작을 가로채고, newPush 를 만들어서 router.push 가 발생할 때 일단 이를 재정의한 newPush 를 사용도록 처리했다. 그 결과 현재 페이지를 벗어나는 것을 방지하면서 경고 모달을 보여줄 수 있게 된다. 참고로 나의 경우에는 replace 로 이동되는 케이스는 없기 때문에 push의 경우만 핸들했다.

`Link` component를 통해 이동하는 경우에도 `newPush`로 재정의된 것을 사용할까?

결론부터 말하자면 '된다'. Next.js의 Link component 코드를 보면 Next.js의 Link component도 결국 client side navigation을 위해서 내부적으로 router.push를 사용하고 있기 때문이다. 이 부분은 중요하기 보다는 개인적으로 궁금해서 찾아봤던 부분인데, 비슷한 궁금증을 가진 분이 있을까봐 한 줄 적어봤다!

import { NavigateOptions } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
 
interface IPushData {
	href: string;
	options?: NavigateOptions | undefined;
}
 
const usePreventRouteChangeLeave = (allowRouteChange: boolean) => {
	const router = useRouter();
	const [showRouteChangeDialog, setShowRouteChangeDialog] = useState(false);
	const [pushData, setPushData] = useState<IPushData>();
	
	const handleRouteChangeDialogLeave = () => {
		setShowRouteChangeDialog(false);
		const { href, options } = pushData ?? {};
		if (href) {
			router.push(href, options);
		}
	};
	
	const handleRouteChangeDialogClose = () => {
		setShowRouteChangeDialog(false);
	};
	
	useEffect(() => {
		const originalPush = router.push;
		
		const newPush = (
			href: string,
			options?: NavigateOptions | undefined,
		): void => {
			if (allowRouteChange) {
				originalPush(href, options);
				return;
			}
			setShowRouteChangeDialog(true);
			setPushData({ href, options });
		};
		
		router.push = newPush;
		
		return () => {
			router.push = originalPush;
		};
	// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [allowRouteChange]);
	
	return {
		showRouteChangeDialog,
		setShowRouteChangeDialog,
		handleRouteChangeDialogLeave,
		handleRouteChangeDialogClose,
	};
};
 
export default usePreventRouteChangeLeave;

3. usePreventBeforeUnloadLeave, usePreventPopStateLeave, usePreventRouteChangeLeave custom hook을 이용해서 페이지 이동 감지하기

3가지 custom hook인 usePreventBeforeUnloadLeave, usePreventPopStateLeave, usePreventRouteChangeLeave을 사용하는 PreventLeaveWrapper component는 페이지 이동을 감지해 현재 페이지에서 벗어나기 전에 경고 모달을 노출하기 위한 것으로 layout.tsx에서 감싸서 사용할 수 있다. 그러면 이제 유저가 페이지를 새로고침하거나 브라우저 창을 닫거나, 뒤로가기 하거나 다른 링크를 클릭하거나 등 페이지를 벗어나는 모든 경우에 바로 이탈하지 않고 경고 모달을 보여줄 수 있게 된다!

참고로 CustomAlertDialog은 Shadcn/ui의 AlertDialog에서 title, description, buttonText 등을 custom하기 위해서 만든 component이다.

"use client";
 
import usePreventBeforeUnloadLeave from "@/hooks/usePreventBeforeUnloadLeave";
import usePreventPopStateLeave from "@/hooks/usePreventPopStateLeave";
import usePreventRouteChangeLeave from "@/hooks/usePreventRouteChangeLeave";
import { useEffect, useState } from "react";
import CustomAlertDialog from "../../../../../components/common/CustomAlertDialog";
 
const PreventLeaveWrapper = ({
  children,
  alertDialogTitle,
  alertDialogDescription,
  leaveAlertDialogActionButtonText,
  closeAlertDialogActionButtonText,
}: {
  children: JSX.Element;
  alertDialogTitle: string;
  alertDialogDescription?: string;
  leaveAlertDialogActionButtonText: string;
  closeAlertDialogActionButtonText: string;
}) => {
  const [allowRouteChange, setAllowRouteChange] = useState(false);
 
  usePreventBeforeUnloadLeave();
 
  const {
    showPopStateDialog,
    setShowPopStateDialog,
    handlePopStateDialogLeave,
    handlePopStateDialogClose,
  } = usePreventPopStateLeave();
 
  const {
    showRouteChangeDialog,
    setShowRouteChangeDialog,
    handleRouteChangeDialogLeave,
    handleRouteChangeDialogClose,
  } = usePreventRouteChangeLeave(allowRouteChange);
 
  const handleLeave = async (onLeave: () => void) => {
    await setAllowRouteChange(true);
    onLeave();
  };
 
  return (
    <>
      {/* 페이지 이탈 (browser go back) */}
      <CustomAlertDialog
        title={alertDialogTitle}
        description={alertDialogDescription}
        primaryActionButtonText={leaveAlertDialogActionButtonText}
        secondaryActionButtonText={closeAlertDialogActionButtonText}
        open={showPopStateDialog}
        onOpenChange={setShowPopStateDialog}
        onPrimaryActionClick={() =>
          handleLeave(handlePopStateDialogLeave)
        }
        onSecondaryActionClick={handlePopStateDialogClose}
      />
 
      {/* 페이지 이탈 (router change) */}
      <CustomAlertDialog
        title={alertDialogTitle}
        description={alertDialogDescription}
        primaryActionButtonText={leaveAlertDialogActionButtonText}
        secondaryActionButtonText={closeAlertDialogActionButtonText}
        open={showRouteChangeDialog}
        onOpenChange={setShowRouteChangeDialog}
        onPrimaryActionClick={() =>
          handleLeave(handlePopStateDialogLeave)
        }
        onSecondaryActionClick={handleRouteChangeDialogClose}
      />
 
      {children}
    </>
  );
};
 
export default PreventLeaveWrapper;

Ref