The problem
Ever spent 10 minutes filling out a form, accidentally clicked a link, and lost everything?
While working on an admin dashboard, I kept running into this. Users would fill out a long form, accidentally hit the browser back button or click a wrong link — and all their work was gone.
The fix seemed simple: "just show a confirmation popup when they try to leave."
Turns out it's more complicated than that.
Three exit paths you need to cover
To properly guard a form, you need to handle all three ways a user can leave:
-
Tab close / page refresh →
beforeunloadevent -
SPA navigation (
<Link>clicks,router.push()) → router-level intercept -
Browser back/forward button →
popstateevent
The first one is easy — just attach a beforeunload listener. The other two are where things get tricky.
Why existing solutions didn't work
React Hook Form's isDirty
formState.isDirty tells you whether the form is dirty. It doesn't block navigation. It's a state value, not a navigation guard.
Next.js Pages Router's router.events
In Pages Router, you could intercept SPA navigation like this:
router.events.on('routeChangeStart', handler)
This worked great. Then App Router came along and removed it entirely. No official replacement. GitHub Issues are full of "how do I do this now?" questions with answers that basically say "copy-paste this 30-line useEffect into every project."
react-router-prompt
React Router only. Doesn't work with Next.js.
What I built
I got tired of copy-pasting the same workaround into every project, so I packaged it into a hook: use-form-guard.
npm install use-form-guard
useFormGuard(isDirty) // that's it
Works with any form library — just pass a boolean:
// React Hook Form
useFormGuard(formState.isDirty)
// Formik
useFormGuard(formik.dirty)
// TanStack Form
useFormGuard(form.state.isDirty)
Custom dialog support too:
useFormGuard({
isDirty,
message: 'You have unsaved changes. Leave anyway?',
onBlock: () => openMyModal(), // return Promise<boolean>
enabled: !isPreviewMode,
})
How it works under the hood
1. Tab close / refresh — beforeunload
window.addEventListener('beforeunload', (e) => {
if (!isDirty) return
e.preventDefault()
e.returnValue = message
})
Modern browsers ignore custom messages for security reasons — you'll always get the browser's native dialog. Nothing we can do about that.
2. SPA navigation — monkey-patching history.pushState
This is the interesting one. In Next.js App Router, both <Link> clicks and router.push() internally call window.history.pushState. So if we patch that, we catch everything.
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)
}
No router.events needed.
3. Browser back/forward — popstate + history.go(1)
When the back button is pressed, popstate fires after the navigation already happened. The trick is to immediately reverse it with history.go(1), then show the confirmation dialog.
window.addEventListener('popstate', () => {
if (!isDirty) return
window.history.go(1) // reverse immediately
setTimeout(() => {
if (window.confirm(message)) window.history.go(-1) // allow if confirmed
}, 100)
})
The setTimeout is needed because history.go(1) is asynchronous — we wait for the history to restore before showing the dialog.
4. Avoiding stale closures — useRef pattern
To keep useEffect dependencies as [] while always reading the latest isDirty value:
const shouldBlockRef = useRef(shouldBlock)
shouldBlockRef.current = shouldBlock // sync on every render
Event listeners read shouldBlockRef.current instead of the captured value.
Bugs I hit along the way
beforeunload tests always failing in jsdom
new Event('beforeunload') defaults to cancelable: false, so e.preventDefault() does nothing and defaultPrevented stays false. Fix:
new Event('beforeunload', { cancelable: true })
TypeScript 6.0 DTS build error
tsup sets baseUrl internally during .d.ts generation, which is deprecated in TypeScript 6.0. Fix:
{
"compilerOptions": {
"ignoreDeprecations": "6.0"
}
}
How it compares
| Feature | use-form-guard | Manual beforeunload | react-router-prompt |
|---|---|---|---|
| Tab close / refresh | ✅ | ✅ | ❌ |
| SPA navigation | ✅ | ❌ | ✅ (RR only) |
| Back/forward button | ✅ | ❌ | ✅ (RR only) |
| Next.js App Router | ✅ | ❌ | ❌ |
| Custom dialog | ✅ | ❌ | ✅ |
| Zero dependency | ✅ | ✅ | ❌ |
Known limitations
-
onBlock(async custom dialog) in App Router may have edge cases due to React concurrent rendering starting beforepushStateis intercepted. Works reliably with thewindow.confirmpath. - Tab close/refresh always shows the browser's native dialog — no way around it.
Wrapping up
What started as a small annoyance turned into a surprisingly deep rabbit hole — jsdom quirks, TypeScript build issues, npm auth problems. But the result is a ~0.8KB hook that replaces a 30-line copy-paste in every project.
npm install use-form-guard
If you try it out and find any bugs or have ideas for improvement, feel free to open an issue!
Top comments (0)