본문 바로가기
프론트엔드 개발/개발 로그

페이지를 벗어나기 전에 prompt 띄워주기 (react-router v6)

by cozyzoey 2022. 11. 21.

사용자 시나리오 

사용자가 어떤 페이지에 접속해서 폼을 작성하다가 브라우저 뒤로가기를 누르거나 내비게이션 메뉴를 클릭할 수 있습니다. 사용자가 실수로 누른건데 열심히 작성중이던 폼 내용이 사라진다면..? 생각만 해도 아찔하네요.

이런 경우를 방지하기 위해서 페이지를 벗어나기 전에 프롬프트(Prompt)를 띄워서 다시 한번 사용자에게 확인을 받으려고 합니다.

페이지 이탈 프롬프트

react-router v5에는 <Prompt>라는 컴포넌트를 제공합니다. 프롬프트에서 보여줄 message와 트리거인 when을 props로 받습니다. 단점은 내부적으로 윈도우의 컨펌을 사용하기 때문에 커스텀 다이알로그를 띄우는 데 한계가 있다는 것입니다. 그런데 안타까운 것은 이 <Prompt> 마저도 react-router v6에서는 사라지고 말았다는 것입니다. (이 좋은걸 왜?)

react-router의 깃헙 이슈를 보면 usePrompt와 useBlocker를 부활시켜달라는 요청이 올라와 있습니다. 2021년에 작성된 글이고 많은 호응을 받았지만 안타깝게도 아직 오픈된 상태네요.

 

usePrompt, useBlocker 직접 구현하기

<Prompt>를 사용하기 위해서 react-router 버전을 v5로 다운그레이드 하기도 그렇고, 커스텀 다이알로그를 적용하기 위해서 react-router v6에서 프롬프트를 직접 구현하기로 합니다.

구현을 위해서 이 블로그 글(Detecting user leaving page with react-router-dom v6.0.2)을 참조했는데요. 그런데 해당 코드를 실행해보니 뒤로가기를 반복해서 누를 경우 A ↔ B 페이지에 갇히게 되는 버그가 있었습니다.

문제는 페이지를 이동시키는 아래 코드입니다. 사용자가 뒤로가기를 누르든, 내비게이션 메뉴를 클릭하든 구분하지 않고 동일하게 아래 코드를 실행하여 페이지를 이동시키는 것이 문제였습니다. 뒤로가기 시에는 브라우저 history의 마지막 상태가 POP되어야 하는데 이 코드는 계속 쌓기만 하여 결과적으로 A ↔ B 마지막 두 페이지에 갇히게 되는 것입니다.

navigate(lastLocation.location.pathname);

 

그래서 페이지를 이동시키는 코드를 수정해보았습니다. useEffect에서 의존하는 상태값들의 조건에 따라 react-router의 nagivate를 실행하는 것과 달리 useBlocker에서 정의한 retry 메서드를 실행하여 페이지 이동을 하도록 했습니다.

useBlocker.ts

import { useEffect, useContext } from 'react';
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom';
import type { History, Blocker, Transition } from 'history';

export const useBlocker = (blocker: Blocker, when = true): void => {
  const navigator = useContext(NavigationContext).navigator as History;

  useEffect(() => {
    if (!when) return;

    const unblock = navigator.block((tx: Transition) => {
      const autoUnblockingTx = {
        ...tx,
        retry() {
          unblock();
          tx.retry();
        },
      };

      blocker(autoUnblockingTx);
    });

    return unblock;
  }, [navigator, blocker, when]);
};

useCallbackPrompt.ts

export const useCallbackPrompt = (when: boolean): [boolean, () => void, () => void] => {
  const location = useLocation();
  const [showPrompt, setShowPrompt] = useState(false);
  const [blockedLocation, setBlockedLocation] = useState<Transition | null>(null);

  const cancelNavigation = useCallback(() => {
    setShowPrompt(false);
    setBlockedLocation(null);
  }, []);

  const blocker = useCallback(
    (tx: Transition) => {
      if (tx.location.pathname !== location.pathname) {
        setBlockedLocation(tx);
        setShowPrompt(true);
      }
    },
    [location]
  );

  const confirmNavigation = useCallback(() => {
    if (blockedLocation) {
      blockedLocation.retry();
      cancelNavigation(); // 클린업
    }
  }, [blockedLocation]);

  useBlocker(blocker, when);

  return [showPrompt, confirmNavigation, cancelNavigation];
};

 

어떻게 사용하는가?

프롬프트를 적용하고자 하는 페이지에서 useCallbackPrompt 훅을 아래와 같이 사용할 수 있습니다. 아래 코드에서 useCallbackPrompt가 받는 when 인수는 true로 하드코딩 되어 있는데요. when은 불리언 값으로 프롬프트를 띄워줄 조건 혹은 트리거입니다. 사용자가 페이지에서 아무것도 작성하지 않았다면 굳이 프롬프트를 띄울 필요가 없겠지요. 만약에 사용자가 폼을 작성중인데 서버에 저장하지 않은 내용이 있을때에 한해 true 조건을 줄 수 있습니다.

import useCallbackPrompt from 'utils/hooks/useCallbackPrompt';
import Alert from 'components/Alert/Alert';

export default function AdminPage() {
  const [showPrompt, confirmNavigation, cancelNavigation] = useCallbackPrompt(true);

  return (
    <>
      <Alert
        isOpen={showPrompt}
        onDoneClick={confirmNavigation}
        onClose={cancelNavigation}
        title="작성중인 내용이 있습니다. 페이지를 벗어나시겠습니까?"
        doneLabel="확인"
      />
      ...
    </>
  );
}

 

댓글