
회원정보 수정 폼을 열심히 채우다가 실수로 뒤로가기를 눌러본 적 있으신가요?
어드민 페이지를 작업하던 중 이 문제를 마주쳤습니다. 사용자가 긴 폼을 채우다가 링크를 잘못 클릭하거나, 뒤로가기를 실수로 누르거나, 탭을 닫아버리는 바람에 작성한 내용이 전부 날아가는 상황이었습니다.
해결책은 단순해 보였습니다. 이탈할 때 확인 팝업만 띄우면 되니깐요.
그런데 막상 구현하려고 보니 생각보다 복잡했습니다.
폼 이탈을 막으려면 사실 세 가지 경로를 모두 처리해야 합니다.
- 탭 닫기 / 새로고침 →
beforeunload이벤트- SPA 내부 이동 (
<Link>클릭,router.push()) → 라우터 레벨 인터셉트- 브라우저 뒤로가기 / 앞으로가기 →
popstate이벤트
1번은 어렵지 않습니다. beforeunload에 리스너 하나 달면 되니까요. 문제는 2번과 3번이었습니다.
React Hook Form의
isDirty
formState.isDirty는 폼이 수정됐는지 여부를 알려줄 뿐이고 이탈 자체를 막는 기능은 없습니다. 상태 값의 역할만 수행했습니다.
Pages Router의
router.events
Next.js Pages Router에서는 router.events.on('routeChangeStart', ...) 로 SPA 이동을 인터셉트할 수 있었습니다. 커뮤니티에 예제도 많고 동작도 문제가 없습니다.
그런데 App Router에서 이 API가 완전히 제거됐습니다. 공식 대안도 없습니다. GitHub Issues에는 이러한 이슈에 관한 질문들이 쌓여 있고, 답변들은 대부분 30줄짜리 useEffect 코드를 복붙하는 방식이었습니다.
react-router-prompt
React Router 전용 라이브러리입니다. Next.js에서는 동작하지 않습니다.
window.addEventListener('beforeunload', (e) => {
if (!isDirty) return
e.preventDefault()
e.returnValue = message
})
모던 브라우저는 보안상 이유로 커스텀 메시지를 무시하고 브라우저 기본 팝업만 표시합니다. 현재까지 어쩔 수 없는 제약입니다.
App Router에서 <Link> 클릭이나 router.push()는 내부적으로 window.history.pushState를 호출합니다. 이걸 패치하면 모든 SPA 이동을 가로챌 수 있습니다.
const original = window.history.pushState
window.history.pushState = function (...args) {
if (isDirty) {
if (window.confirm(message)) original.apply(this, args)
return
}
original.apply(this, args)
}
router.events가 없어도 됩니다. Next.js가 결국 pushState를 쓰기 때문에 여기서 잡을 수 있습니다.
뒤로가기를 누르면 popstate 이벤트가 발생하는데, 이미 히스토리가 이동한 이후입니다. 이걸 막으려면 즉시 history.go(1)로 되돌린 다음, 사용자에게 확인을 받아야 합니다.
window.addEventListener('popstate', () => {
if (!isDirty) return
window.history.go(1) // 즉시 되돌리기
setTimeout(() => {
if (window.confirm(message)) window.history.go(-1) // 허용하면 이동
}, 100)
})
setTimeout이 있는 이유는 history.go(1)이 비동기로 처리되기 때문에, 히스토리가 복원된 이후에 confirm을 띄우기 위함입니다.
useEffect 의존성 배열을 []로 유지하면서 최신 isDirty 값을 참조하려면 ref가 필요합니다.
const shouldBlockRef = useRef(shouldBlock)
shouldBlockRef.current = shouldBlock // 매 렌더마다 동기화
이벤트 리스너 안에서 shouldBlockRef.current를 읽으면 항상 최신 값을 가져올 수 있습니다.
사용하기 쉽게 두 가지 형태를 모두 지원합니다.
// 가장 단순한 사용법
useFormGuard(isDirty)
// 옵션 포함
useFormGuard({
isDirty,
message: '저장하지 않고 나가시겠습니까?',
onBlock: () => openMyModal(), // Promise<boolean>을 반환하는 커스텀 모달
enabled: !isPreviewMode, // 조건부 비활성화
})
// 반환값
const { isBlocked } = useFormGuard(isDirty)
onBlock은 window.confirm 대신 자체 모달을 쓰고 싶을 때 사용합니다. Promise<boolean>을 반환하면 되고, true면 이동 허용, false면 차단입니다.
어떤 폼 라이브러리든 boolean 값만 넘기면 동작합니다.
// React Hook Form
useFormGuard(formState.isDirty)
// Formik
useFormGuard(formik.dirty)
// TanStack Form
useFormGuard(form.state.isDirty)
// useState
useFormGuard(isDirty)
Vitest + jsdom 환경에서 beforeunload 이벤트를 발생시켜도 핸들러가 실행되지 않았습니다. 원인은 new Event('beforeunload')가 기본적으로 cancelable: false라서 브라우저가 무시한 것이였습니다.
// 이렇게 하니까 됐음
new Event('beforeunload', { cancelable: true })
tsup으로 빌드할 때 타입 선언 파일(.d.ts) 생성에서 에러가 났습니다. tsup이 내부적으로 baseUrl을 설정하는데 TypeScript 6.0에서 이게 deprecated 됐기 때문이였습니다.
// tsconfig.json
{
"compilerOptions": {
"ignoreDeprecations": "6.0"
}
}
| 항목 | 내용 |
|---|---|
| 번들 크기 | ~1.75KB minified (~0.8KB gzipped) |
| 의존성 | 없음 (React ≥ 17 peerDependency만) |
| 빌드 | tsup (ESM/CJS 듀얼 빌드 + d.ts) |
| 테스트 | Vitest + React Testing Library (27개) |
다른 라이브러리와 비교하면 이렇습니다.
| 기능 | use-form-guard | 수동 beforeunload | react-router-prompt |
|---|---|---|---|
| 탭 닫기/새로고침 | ✅ | ✅ | ❌ |
| SPA 이동 차단 | ✅ | ❌ | ✅ (RR만) |
| 뒤로가기 차단 | ✅ | ❌ | ✅ (RR만) |
| Next.js App Router | ✅ | ❌ | ❌ |
| 커스텀 다이얼로그 | ✅ | ❌ | ✅ |
| Zero dependency | ✅ | ✅ | ❌ |
작은 문제 하나에서 시작했는데, 제대로 만들려고 하니 꽤 많은 엣지 케이스가 있었습니다. jsdom 이슈부터 TypeScript 빌드 문제, npm 배포 에러까지 하나씩 해결하면서 생각보다 많이 배웠습니다.
결과물은 작지만 (0.8KB), 실제로 매 프로젝트마다 복붙하던 코드를 한 줄로 대체할 수 있게 됐습니다.
npm install use-form-guard
useFormGuard(isDirty) // 끝