[Next.js 14] 새로고침, 뒤로가기, 페이지 이동 감지

mia·2024년 6월 17일
1

프로젝트를 하면서 새로고침 & 창닫기, 뒤로가기, 페이지 이동에 대한 핸들링을 해야했고 작업하면서 학습한 내용에 대해 정리해보려고 한다.

1. 새로고침 & 창닫기 이벤트

새로고침 & 창닫기 이벤트의 경우 "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])

2. 뒤로가기

뒤로가기 이벤트의 경우 처음 페이지에 진입했을 때, 현재 페이지를 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);
}

3. 페이지 이동

페이지 이동은 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);
}

4. 모두 합친 코드

//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"
    />
  );
}
profile
노 포기 킾고잉

0개의 댓글

관련 채용 정보