들어가며
새로 알게 된 지식으로 오랜만에 글을 써보려고 한다.
프로젝트를 진행하면서 신청폼을 구현하고 있는 와중에 하나의 피드백이 들어왔다.
신청서에서 뒤로가기를 눌렀을 때 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 를 쓰기 위해 새로운 라우터 구조도 접해보고 좋은 경험이었다.
다음에는 프로젝트에서 이러한 부분까지 고려해가며 설계할 수 있을 것 같다!