[next.js, react-hook-form] 폼 입력 페이지 이탈 방지 기능 구현

·2024년 8월 25일
1

개발 기록

목록 보기
61/68

폼 입력 페이지에서 사용자가 작성 중인 내용을 실수로 잃어버리는 것은 끔찍한 경험이다. 우리 프로젝트는 길면 1시간도 넘게 폼을 사용하기 때문에 더더욱 필요한 부분이었다.
이런 상황을 방지하기 위해, 사용자가 입력하던 내용을 실수로 날려버리지 않도록 이탈 방지 기능을 구현했다.

고려해야 할 시나리오는 다음과 같다.

1. 브라우저 닫기: 사용자가 브라우저를 닫으려 할 때.
2. 페이지 새로고침: 사용자가 페이지를 새로고침할 때.
3. 페이지 이동: 사용자가 다른 페이지로 이동할 때 (예: 뒤로 가기, 앞으로 가기, URL 직접 입력 등).

beforeunload로 페이지 이탈 방지하기

먼저 beforeunload 이벤트를 사용하면 1번과 2번을 시나리오를 막을 수 있다. 이 이벤트는 사용자가 페이지를 떠나려고 할 때 브라우저가 경고 메시지를 표시하도록 한다.

  useEffect(() => {
    const handleBeforeunload = (e: {
      preventDefault: () => void;
      returnValue: string;
    }) => {
      e.preventDefault();
      e.returnValue = '';
    };

   window.addEventListener('beforeunload', handleBeforeunload);

    return () => {
      window.removeEventListener('beforeunload', handleBeforeunload);
    };
  }, []);

여기까지 막으면 다 막혔다고 생각했지만 SPA에서는 끝이 아니다!

SPA에서 페이지 이탈 방지하기

우리 프로젝트의 폼 입력 페이지에는 제작 내역 페이지로 이동하는 next.js의 Link를 사용한 버튼이 있다. 위의 beforeunload로 기능을 구현하고 페이지 이동을 했더니 이벤트가 동작하지 않는다!

SPA를 풀어쓰면 단일 페이지 앱! 당연히 페이지 이동이 없다. 그래서 이벤트가 발생이 안된다.
Next.js에서 제공하는 router의 이벤트를 사용해서 페이지 이동을 감지할 수 있다.

  useEffect(() => {
    const handleBlockPopState = ({ url }: { url: string }) => {
      if (router.asPath !== url) {
        window.history.pushState(null, '', router.asPath);
      }
      return true;
    };

    const handleBeforeHistoryChange = () => {
      if (
        window.confirm(
          '변경사항이 저장되지 않을 수 있습니다. 사이트에서 나가시겠습니까?',
        )
      ) {
        router.events.emit('routeChangeComplete');
      } else {
        router.events.emit('routeChangeError');
      }
    };

    const handleRouteChangeError = () => {
      throw 'Route change aborted.';
    };

      router.events.on('beforeHistoryChange', handleBeforeHistoryChange);
      router.events.on('routeChangeError', handleRouteChangeError);
      router.beforePopState(handleBlockPopState);

    return () => {
      router.events.off('beforeHistoryChange', handleBeforeHistoryChange);
      router.events.off('routeChangeError', handleRouteChangeError);
      router.beforePopState(() => true);
    };
  }, []);

handleBeforeHistoryChange

Next.js 공식문서 router.events

beforeHistoryChange(url, { shallow }) - Fires before changing the browser's history(브라우저 히스토리가 변경되기 전에 발동)

beforeHistoryChange 이벤트 발생 시에 컨펌창을 띄우고, 컨펌 창의 값에 따라 뒤로 가기를 마저 완료하거나 에러를 일으키도록 한다.

handleBlockPopState

뒤로 가기를 하고 팝업을 띄우면 beforeHistoryChange에 잡히기 전 Url이 바뀌어버려서 취소를 눌러도 뒤로 가기 한 후의 주소가 나타난다. 이를 방지하기 위한 함수이다.

  • localhost:3000로 페이지는 그대로인데 url이 바뀌어 버림

  • localhost:3000/editor 현재 페이지의 url을 유지

handleRouteChangeError

팝업에서 취소를 눌렀을 때 에러를 던져 페이지 이동을 막기 위한 함수이다. 이 함수가 없다면 취소를 눌러도 뒤로 가기가 동작해버린다.

폼 입력에 변경사항이 있을 때만 뒤로 가기 방지 기능이 동작하도록 하기

입력한 게 없다면 폼을 이탈해도 무방하다. 이를 위해 커스텀 훅에서 외부로부터 불리언 값을 받아, 해당 값에 따라 이벤트를 발생시킬지 여부를 결정하도록 추가로 구현한다. 이 불리언 값은 입력값이 변경되었는지를 나타낸다.

const usePreventFormExit = (isEnabled = true) => {
  const router = useRouter();

  useEffect(() => {
    const handleBeforeunload = (e: {
      preventDefault: () => void;
      returnValue: string;
    }) => {
      e.preventDefault();
      e.returnValue = '';
    };

    const handleBlockPopState = ({ url }: { url: string }) => {
      if (router.asPath !== url) {
        window.history.pushState(null, '', router.asPath);
      }
      return true;
    };

    const handleBeforeHistoryChange = () => {
      if (
        window.confirm(
          '변경사항이 저장되지 않을 수 있습니다. 사이트에서 나가시겠습니까?',
        )
      ) {
        router.events.emit('routeChangeComplete');
      } else {
        router.events.emit('routeChangeError');
      }
    };

    const handleRouteChangeError = () => {
      throw 'Route change aborted.';
    };

    if (isEnabled) {
      window.addEventListener('beforeunload', handleBeforeunload);
      router.events.on('beforeHistoryChange', handleBeforeHistoryChange);
      router.events.on('routeChangeError', handleRouteChangeError);
      router.beforePopState(handleBlockPopState);
    } else {
      window.removeEventListener('beforeunload', handleBeforeunload);
      router.events.off('beforeHistoryChange', handleBeforeHistoryChange);
      router.events.off('routeChangeError', handleRouteChangeError);
      console.log(window.history);
      console.log(router.asPath);
      router.beforePopState(() => true);
    }

    return () => {
      window.removeEventListener('beforeunload', handleBeforeunload);
      router.events.off('beforeHistoryChange', handleBeforeHistoryChange);
      router.events.off('routeChangeError', handleRouteChangeError);
      router.beforePopState(() => true);
    };
  }, [isEnabled]);
};

현재 프로젝트에서는 react-hook-form을 사용하고 있어 isDirty값을 사용해 판단한다. formState에 다른 상태들도 있어 고려해 봤는데 isDirty로 충분하다 판단했다.

const isEnabled =
    methods.formState.isDirty && !methods.formState.isSubmitting;

usePreventFormExit(isEnabled);

마무리

처음에 beforeunload만 달면 끝난다!라고 단순히 생각했으나 왜 안돼? 가 계속 발생한 폼 이탈 방지...잡을 수 있는 부분은 최대한 잡아서 구현했는데 빠진 게 있다면 알려주시면 감사드립니다..🙇‍♀️

참고자료

0개의 댓글