개발/React

[React] useBlocker 를 사용해 페이지 이동 차단하기 (페이지 이탈 프롬프트 Prompt 생성하기)

hayeonn 2025. 4. 15. 23:06
반응형

 

들어가며

 

새로 알게 된 지식으로 오랜만에 글을 써보려고 한다.

프로젝트를 진행하면서 신청폼을 구현하고 있는 와중에 하나의 피드백이 들어왔다.

신청서에서 뒤로가기를 눌렀을 때 Prompt 창을 띄워 사용자에게 메세지를 띄워주는 것 처럼 

헤더에 있는 로고, 대메뉴, 마이페이지를 눌렀을 때도 같은 Prompt 창을 띄워줄 수 있는지 말이다!

 

처음에는 사용자가 뒤로가기, 새로고침을 했을때만 페이지 이동을 차단하도록 막아놨었는데 

헤더에 있는 무언가를 눌렀을 때 이동을 막는 것은 처음 시도해봐서 이것저것 시도해보다 useBlocker 라는 훅을 발견했다!

중구난방인 방법들을 헤매다 적용에 성공했다.

 


 

react-router-dom v6

 

화면 이탈 프롬프트를 만들기위해서 react-router-dom 의 최신버전으로 유지하는편이 좋다.

v5 에서는 <Prompt> 를 제공했었는데 사라졌다! 따라서 <Prompt> 를 더이상 사용할 수 없고, 

우리는 최신버전을 유지하며 그에 맞게 나온 useBlocker 를 사용해 만들어주면 된다.

 

다른 블로그들을 찾아보면서, retry 메서드를 사용하라는 등 복잡하게 짜여져있어서 꽤나 헤맸다.

차근차근 적용해보기 위해서 일단 내가 만든 신청 폼에 useBlocker 를 불러오도록 했다.

 

useBlocker 

이 훅은 현재 pathname 과 이동할 pathname 을 비교해 해당 페이지에서 이동을 차단(blocked) 한다.

내가 코드를 짜놓은 신청 컴포넌트는 양이 상당해서 Test.jsx 컴포넌트에 실험을 해봤다.

 

test code

  // * --- 이동 막기 test
  // value 가 있고 pathname 이 다르면 blocked
  const navigate = useNavigate();
  const [value, setValue] = useState("");

  const blocker = useBlocker(
    ({currentLocation, nextLocation}) => value !== "" 
    && currentLocation.pathname !== nextLocation.pathname,
  );
  
  
  // * --- form (value 업데이트 하는 곳)
  <form onSubmit={handleSubmit}>
        <label>
          중요한 데이터 등록!!
          <input name={"data"} value={value} onChange={(e) => setValue(e.target.value)} />
        </label>
        <button type={"button"} onClick={() => navigate("/")}>
          홈페이지로 이동하기
        </button>

        {blocker.state === "blocked" ? (
          <div>
            <p>떠나실래요?</p>
            <button onClick={() => blocker.proceed()}>네</button>
            <button onClick={() => blocker.reset()}>아니오</button>
          </div>
        ) : null}
      </form>

 

테스트 컴포넌트에 이와 같은 코드를 작성했다.

 

1. 사용자가 value 를 작성했다.

2. value 가 있는 상태에서 사용자가 페이지를 이동(navigate) 하려고 한다.

3. 이때 blocker.state 가 "blocked" 로 변경되며 페이지 이동이 차단되고 사용자에게 "떠나실래요?" 라는 문구가 노출된다.

4. 네 -> 홈페이지로 이동 / 아니오 -> 해당 화면에서 계속 머무름

 

 

위와 같이 값을 입력 후 "홈페이지로 이동하기" 버튼을 누르면 밑에 문구가 출력된다!

테스트 한 것과 비슷하게 실제 신청 폼에 적용해보자.

 

createBrowserRouter

역시 한 번에 될리가 없다. 

useBlocker must be used within a data router.

이러한 오류가 터지면서 돌아가지 않음!!!! 

 

이유인즉슨, react-router-dom 이 6.4 로 업데이트 되면서 data api 를 지원하는 라우터가 따로 생겼다.

원래 프로젝트의 라우터 구조는 이와 같았다.

function App() {
  return (
    <BrowserRouter>
      <MainRouetes />
      <ToastContainer
        position="top-right" // 알람 위치 지정
      />
      <AlertDialog />
      <ConfirmDiaLog />
    </BrowserRouter>
  );
}

 

기존의 BrowserRouter 는 data api 를 지원해주지 않아 useBlocker 를 사용할 수 없다.

 

🔗 react-router-dom createBrowserRouter 알아보기

 

따라서, 나는 위와 같은 구조를 변경하는 작업에 돌입했다.

 

const router = createBrowserRouter(
  [
    {
      path: "/",
      element: <UserMainLayout />,
      children: [
        {index: true, element: <MainUserPage />},
        {
          path: "auth",
          children: [
            {path: "login", element: <UserLogin />},
            {path: "sign", element: <UserSignUp />},
            {path: "login-help", element: <UserLoginHelp />},
            {path: "mypage", element: <UserMypage />},
            {path: "user-edit", element: <UserModifyPage />},
          ],
        },
        {
          path: "about",
          children: [{path: "aboutPage", element: <About />}],
        },
      ],
    },
 
  ],
  // v7 경고 삭제
  {
    future: {
      v7_relativeSplatPath: true,
      v7_startTransition: true,
    },
  },
);

 

이 부분은 MainRoutes 부분이고, 아래와 같이 Routes 로 감싸진 부분을 위 처럼 변경했다.

 <Routes>
        <Route path="/" element={<Main />} />
        <Route path="/login" element={<Login />} />
        <Route path="/error" element={<Error />} />
 </Routes>

 

여기서 future 은 콘솔에 나는 warning 을 없애기 위해 추가해준 부분이다. (참고하실 분은 참고하세요!)

 

(1) BlockedDialog 생성

라우터 구조를 변경하고 나서 제일 처음으로 한 것은 사용자에게 띄워줄 모달을 만들었다.

참고로 해당 프로젝트는 mui 라이브러리를 사용하고 있어서 라이브러리 내에 있는 컴포넌트를 사용했다.

 

export default function BlockedDialog({message, isBlocking}) {
  let blocker = useBlocker(isBlocking);

  const handleClose = () => blocker.reset(); // blocked 유지해 페이지 이동 막음
  const handleConfirm = () => blocker.proceed(); // unblocked 상태로 변경되며 페이지 이동됨

  return (
    <Dialog
      open={blocker.state === "blocked"} // blocked 일 경우 모달 열기
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogContent>
        <DialogContentText color={"#333"} id="alert-dialog-description">
          {message}
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button onClick={handleClose} color={"error"}>
          취소
        </Button>
        <Button onClick={handleConfirm} color={"success"}>
          확인
        </Button>
      </DialogActions>
    </Dialog>
  );
}

 

내가 직접 만든 dialog 는 이와 같고, 받아오는 blocker 상태(isBlocking)를 전역변수로 지정했다.

전역변수로 지정한 이유는 (1) 유저가 로그아웃을 눌렀을 때 blocker 가 작동하지 않고 바로 로그아웃이 되어야 함. 그리고 (2) 신청서 내 "신청 취소" 를 할 경우 blocker 가 작동하지 않고 신청취소가 바로 되어야 함. (3) 사용자가 신청서 페이지에서 신청하기 버튼을 누를 때 blocking 없이 바로 신청이 되어야 함.

 

이와 같이 3가지 이유가 있었다. (2, 3) 번은 신청서 내부에 있어 상태관리를 하면야 가능하겠지만 (1) 은 로그아웃 액션 자체가 다른 곳에 있었기 때문에 blocker 상태를 전역으로 관리하고자 했다.

 

(2) isBlocking 상태 전역 변수 설정하기

import {createSlice} from "@reduxjs/toolkit";

const blockingSlice = createSlice({
  name: "blocking",
  initialState: {
    isBlocking: true,
  },
  reducers: {
    enableBlocking: (state) => {
      state.isBlocking = true; // 차단 활성화
    },
    disableBlocking: (state) => {
      state.isBlocking = false; // 차단 비활성화 (로그아웃, 신청 취소, 신청 할 때 필요)
    },
  },
});

export const {enableBlocking, disableBlocking} = blockingSlice.actions;
export default blockingSlice;

 

이런식으로 리덕스를 사용해서 전역변수로 설정을 해줬다.

 

(3) 신청페이지에서 BlockedDialog 사용하기

<BlockedDialog
	message={"해당 페이지를 벗어나면 임시저장하지 않은 작성내용과 첨부한 파일은 초기화됩니다. 해당 페이지를 나가시겠습니까?"}
	isBlocking={isBlocking}
/>

 

이런식으로 내가 직접 만든 BlockedDialog 를 신청서 페이지 내부에 작성했다.

 

  // * --- 페이지 진입 시 isBlocking 안전하게 true 로 변경시키기
  useEffect(() => {
    dispatch(enableBlocking());
  }, [dispatch]);

 

추가적으로 신청서 페이지에 (최초) 진입할 때 useEffect 를 사용해 isBlocking 상태를 안전하게 true 로 설정해줬다.

 

  const handleSubmit = async () => {
    dispatch(disableBlocking()); // blocking 해제 해 바로 신청되도록 함
    ...
}

 

그리고 사용자가 신청서를 최종 제출 할 때는 BlockedDialog 가 나와서는 안되기 때문에 최종 제출 함수에는 isBlocking 상태를 false 로 변경해준다! 그래야 해당 모달이 뜨지 않는다.

 

추가적으로 사용자가 헤더에서 바로 로그아웃하는 부분에도 코드를 넣어준다. 

  // 로그아웃
  const handleLogout = async (e) => {
    e.preventDefault();
    try {
      dispatch(disableBlocking()); // 신청서에서 로그아웃시 blocking 해제 후 바로 로그아웃 처리
      ....
   }

 

이런식으로 사용자가 누르는 헤더 또는 다른 버튼들 어디서든 blocking 상태를 다룰 수 있게 된다.

 

마치며

복잡한 신청서 페이지를 구현하면서 다양한 경우의 수를 생각하게 되었고, 임시저장 기능만 구현하면 될 것 같았는데 사용자가 신청서를 작성하다가 임시저장을 하지 않고, 다른 페이지로 이동할 때 작성해온 내용을 다 날릴 수도 있다는 부분을 인지하게 되었다.

이 부분을 구현하기 위해서 많은 정보를 찾아봤는데.. 버전 이슈때문에 삽질을 많이 했던 것 같다.

라우터 구조까지 변경해가며 구현하게 될 줄은 몰랐는데, 덕분에 data api 를 쓰기 위해 새로운 라우터 구조도 접해보고 좋은 경험이었다.

다음에는 프로젝트에서 이러한 부분까지 고려해가며 설계할 수 있을 것 같다!