Skip to content

뒤로가기 confirm 로직 구현하기

juhyojeong edited this page Sep 1, 2024 · 3 revisions

뒤로가기 confirm 로직 구현하기

react-router-dom v5에서는 history 라이브러리를 사용해서 직접 history 객체에 접근할 수 있었다. 그래서 history 객체의 block API를 사용해서 뒤로가기를 막을 수 있었다.

import React, { useEffect } from 'react';
import { Router, Route, Routes, useLocation } from 'react-router-dom';
import { createBrowserHistory } from 'history';

const history = createBrowserHistory();

function App() {
  return (
    <Router history={history}>
      <Routes>
        <Route path="/" element={<Home />} />
      </Routes>
    </Router>
  );
}

function Home() {
  useBlockNavigation();

  return <h2>Home</h2>;
}

function useBlockNavigation() {
  const location = useLocation();

  useEffect(() => {
    const unblock = history.block((tx) => {
      if (window.confirm("정말 이 페이지를 떠나시겠습니까?")) {
        unblock();
        tx.retry();
      } else {
        return false;
      }
    });

    return () => unblock();
  }, [location]);
}

export default App;

그런데 react-router-dom v6부터는 useNavigate hook을 사용해서 navigate 함수를 기반으로 경로를 이동하기 때문에 history 객체를 직접적으로 사용할 수 없다.

다른 방법을 찾아보던 중 브라우저의 popstate 이벤트를 활용해보기로 했다. popstate 이벤트는 브라우저의 history stack이 변할때 발생하는 이벤트인데, 브라우저의 뒤로 가기나 앞으로 가기 버튼을 클릭할 때 발생한다. 뒤로가기 버튼이 트리거될 때 해당 이벤트가 발생하니까 이벤트가 발생할 때 뒤로가기를 prevent 해서 confirm 창을 띄우고 사용자가 뒤로가기에 동의하면 pop, 뒤로가기에 동의하지 않으면 그대로 경로에 남아있도록 구현하면 되겠다고 생각했다.

1차 문제 - popstate는 preventDefault가 안된다

popstate 이벤트는 preventDefault()를 사용할 수 없다. popstate 이벤트는 단순히 브라우저의 히스토리 상태가 변경될 때 발생하며, 이 이벤트는 브라우저의 기본 동작을 직접적으로 막을 수 없기 때문이다.

따라서 preventDefault를 직접 하는 것이 아니라 그렇게 보이도록 우회해서 만들어야한다.

import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";

export default function useConfirmNavigation(message: string) {
    const navigate = useNavigate();
    const location = useLocation();
    const [preventBack, setPreventBack] = useState(false);

    useEffect(() => {
        const handlePopState = (e: PopStateEvent) => {
            if (preventBack) {
                const confirm = window.confirm(message);
                if (!confirm) {
                    navigate(location.pathname, { replace: true });
                }
            }
        };

        window.addEventListener("popstate", handlePopState);

        return () => {
            window.removeEventListener("popstate", handlePopState);
        };
    }, [navigate, location.pathname, message, preventBack]);

    useEffect(() => {
        setPreventBack(true);
        return () => {
            setPreventBack(false);
        };
    }, []);
}

이런 식으로 구현할 수 있는데, 하나하나 살펴보자면 사용자가 브라우저의 뒤로가기 버튼을 클릭하면 popstate 이벤트가 발생한다. handlePopState 내에서는 window.confirm(message)를 사용하여 사용자에게 확인 대화 상자를 표시한다. 사용자가 취소를 선택하면 !confirmtrue가 되고, navigate(location.pathname, { replace: true })를 통해 현재 페이지로 다시 이동한다.

2차 문제 - 정상적으로 동작하지 않는다

_._2024-08-08_23.18.53.mov

뒤로 가기를 여러번 누르는 경우 뒤로 가기 방지 confirm 창이 뜨지 않고, 또 앞으로 가기를 하는 경우에도 뒤로가기 방지 창이 뜨는 등 이상하게 동작했다.

그 이유를 생각해보았는데, popstate 이벤트는 브라우저에서 뒤로 가기 버튼을 누를 때 뿐만 아니라 앞으로 가기 버튼을 클릭할 때 등 여러 상황에서 발생한다. 결국 popstate 이벤트는 브라우저의 히스토리 스택의 상태가 변경될 때 발생하고 브라우저의 이동 동작을 제어하는 기능이 아니기 때문에 우회해서 구현하는 방식으로는 한계가 있는 것으로 판단했다.

usePrompt

react-router-dom에서 제공하는 usePrompt hook은 페이지 이탈 시 사용자에게 경고 메시지를 표시하는 데 사용된다. 페이지 이탈 전 발생하는 beforeunload 이벤트와 어떤 차이가 있나 싶었는데, usePrompt는 react router의 네비게이션 context에서 동작하기 때문에 spa의 history 제어를 더 적합하게 처리할 수 있다고 볼 수 있다.

import { useEffect, useState } from "react";
import { unstable_usePrompt, useLocation } from "react-router-dom";

export function useBlockNavigation(message: string) {
    const location = useLocation();
    const [isBlocking, setIsBlocking] = useState(false);

    unstable_usePrompt({ when: isBlocking, message });

    const unblockNavigation = () => {
        setIsBlocking(false);
    };

    useEffect(() => {
        setIsBlocking(true);

        return () => {
            setIsBlocking(false);
        };
    }, [location]);

    return { unblockNavigation };
}

usePrompt hook을 기반으로 위와 같이 뒤로가기 방지 코드를 작성할 수 있다.

unstable_usePrompt({ when: isBlocking, message });

이 코드가 핵심 코드이다. when은 prompt의 활성화 여부를 의미하고 message는 prompt에 나타날 메시지이다. 해당 hook을 선언해두면 hook이 선언된 페이지에서 벗어나는 경우 prompt가 뜨게 되고 뒤로가기 여부를 사용자에게 확인받을 수 있게 된다.

image

추가)

useEffect(() => {
    setIsBlocking(true);

    return () => {
        setIsBlocking(false);
    };
}, [location]);

또한 이 코드는 마운트/언마운트 시 뒤로가기 동작 blocking을 제어하기 위한 코드이다. 컴포넌트가 마운트되면 preventBacktrue로 설정되어 뒤로가기 방지를 하고, 컴포넌트가 언마운트되면 preventBackfalse로 설정되어 뒤로가기 버튼이 정상적으로 동작한다.

❗️ 이후 수정사항

alert가 UX적으로 좋지 않은 것 같다는 피드백을 받아 직접 useBlocker를 사용해서 alert 창을 커스텀 하도록 수정했다.

export function useBlockNavigation() {
    const location = useLocation();
    const [isBlocking, setIsBlocking] = useState(false);

    const blocker = useBlocker(isBlocking);
    const isBlockedNavigation = blocker.state === "blocked";

    const unblockNavigation = () => {
        setIsBlocking(false);
    };

    const cancelNavigation = () => {
        if (blocker.state === "blocked") {
            blocker.reset();
        }
    };

    const proceedNavigation = () => {
        if (blocker.state === "blocked") {
            blocker.proceed();
        }
    };

    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
        if (isBlocking) {
            e.preventDefault();
            e.returnValue = "";
        }
    };

    useEffect(() => {
        setIsBlocking(true);

        return () => {
            setIsBlocking(false);
        };
    }, [location]);

    useEffect(() => {
        window.addEventListener("beforeunload", handleBeforeUnload);

        return () => {
            window.removeEventListener("beforeunload", handleBeforeUnload);
        };
    }, [isBlocking]);

    return { unblockNavigation, isBlockedNavigation, proceedNavigation, cancelNavigation };
}

📚 학습 정리

🗂️ 멘토링

Clone this wiki locally