Framework-agnostic React hook to prevent accidental navigation when a form has unsaved changes.
Works with React Hook Form, Formik, TanStack Form, or any boolean dirty state.
Covers browser tab close, SPA navigation, and the browser back/forward button — including Next.js App Router.
Every form-heavy app needs this pattern:
User types something → clicks a link → browser asks "You have unsaved changes. Leave?"
React Hook Form gives you formState.isDirty, but it does not handle:
- Browser tab close / refresh (
beforeunload) - SPA navigation (
router.push(),<Link>clicks) - The browser back / forward button (
popstate)
Next.js App Router made this especially painful by removing the router.events API that Pages Router developers relied on. There is no official solution — developers have been copy-pasting 30+ line hacks in every project.
use-form-guard solves all of this in one hook.
- Zero dependencies — only requires React ≥ 17 as a peer
- Framework-agnostic — works with React Hook Form, Formik, TanStack Form,
useState, anything - Covers all exit paths — tab close, SPA navigation, back/forward button
- Next.js App Router support — patches
history.pushStateto intercept<Link>clicks androuter.push() - Custom dialog support — bring your own modal instead of the native
window.confirm() - TypeScript-first — full type inference, no
@types/*needed - Tiny — ~1.75 KB minified (~0.8 KB gzipped)
npm install use-form-guard
# or
pnpm add use-form-guard
# or
yarn add use-form-guardimport { useForm } from 'react-hook-form'
import { useFormGuard } from 'use-form-guard'
function EditProfileForm() {
const { register, handleSubmit, formState: { isDirty } } = useForm()
useFormGuard(isDirty)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<button type="submit">Save</button>
</form>
)
}import { useState } from 'react'
import { useFormGuard } from 'use-form-guard'
function EditForm() {
const [isDirty, setIsDirty] = useState(false)
useFormGuard(isDirty)
return (
<textarea onChange={() => setIsDirty(true)} />
)
}import { useFormik } from 'formik'
import { useFormGuard } from 'use-form-guard'
function MyForm() {
const formik = useFormik({ ... })
useFormGuard(formik.dirty)
return <form onSubmit={formik.handleSubmit}>...</form>
}import { useFormGuard } from 'use-form-guard'
import { openConfirmModal } from './my-modal'
function EditForm() {
const [isDirty, setIsDirty] = useState(false)
useFormGuard({
isDirty,
onBlock: () => openConfirmModal({
title: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
}),
// openConfirmModal must return Promise<boolean>
// true → allow navigation
// false → stay on page
})
return <form>...</form>
}useFormGuard({
isDirty,
message: 'Your draft will be lost. Leave anyway?',
})const [isDirty, setIsDirty] = useState(false)
const [isPreviewMode, setIsPreviewMode] = useState(false)
useFormGuard({
isDirty,
enabled: !isPreviewMode, // disable guard in preview mode
})useFormGuard(isDirty: boolean, options?: Options): ResultuseFormGuard(options: Options): Result| Property | Type | Default | Description |
|---|---|---|---|
isDirty |
boolean |
— | Whether the form has unsaved changes. |
message |
string |
'You have unsaved changes. Are you sure...' |
Message for the window.confirm() SPA navigation dialog. |
onBlock |
() => Promise<boolean> |
undefined |
Custom async dialog. Return true to allow, false to block. Not called on tab close / refresh (browser security restriction). |
enabled |
boolean |
true |
Set to false to temporarily disable without changing isDirty. |
| Property | Type | Description |
|---|---|---|
isBlocked |
boolean |
Whether navigation is currently being guarded. |
use-form-guard guards three exit paths:
| Exit path | Mechanism |
|---|---|
| Tab close / refresh / hard navigation | window.beforeunload event |
SPA navigation (<Link>, router.push()) |
history.pushState / replaceState patching |
| Browser back / forward button | popstate event + history.go() |
In Next.js App Router, router.events was removed. use-form-guard works around this by patching window.history.pushState and window.history.replaceState — the same methods that Next.js's router calls internally when navigating.
Note: For full reliability with custom async dialogs (
onBlock) in Next.js App Router, it is recommended to test your specific setup, as React's concurrent rendering may have already started a transition beforepushStateis intercepted.
| Feature | use-form-guard |
Manual beforeunload |
react-router-prompt |
|---|---|---|---|
| Tab close / refresh | ✅ | ✅ | ❌ |
<Link> / router.push() |
✅ | ❌ | ✅ (React Router only) |
| Browser back / forward | ✅ | ❌ | ✅ (React Router only) |
| Next.js App Router | ✅ | ❌ | ❌ |
| Custom dialog | ✅ | ❌ | ✅ |
| Framework-agnostic | ✅ | ✅ | ❌ |
| Zero dependencies | ✅ | ✅ | ❌ |
| TypeScript-first | ✅ | manual | ✅ |
Contributions, issues, and feature requests are welcome.
Please check the issues page before submitting.
MIT © rl0425