Skip to content

rl0425/use-form-guard

Repository files navigation

use-form-guard

npm version npm downloads bundle size license TypeScript

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.


The Problem

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.


Features

  • 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.pushState to intercept <Link> clicks and router.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)

Installation

npm install use-form-guard
# or
pnpm add use-form-guard
# or
yarn add use-form-guard

Usage

With React Hook Form

import { 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>
  )
}

With useState (or any boolean)

import { useState } from 'react'
import { useFormGuard } from 'use-form-guard'

function EditForm() {
  const [isDirty, setIsDirty] = useState(false)

  useFormGuard(isDirty)

  return (
    <textarea onChange={() => setIsDirty(true)} />
  )
}

With Formik

import { useFormik } from 'formik'
import { useFormGuard } from 'use-form-guard'

function MyForm() {
  const formik = useFormik({ ... })

  useFormGuard(formik.dirty)

  return <form onSubmit={formik.handleSubmit}>...</form>
}

With a custom dialog (no window.confirm)

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>
}

With a custom message

useFormGuard({
  isDirty,
  message: 'Your draft will be lost. Leave anyway?',
})

Temporarily disable the guard

const [isDirty, setIsDirty] = useState(false)
const [isPreviewMode, setIsPreviewMode] = useState(false)

useFormGuard({
  isDirty,
  enabled: !isPreviewMode, // disable guard in preview mode
})

API

useFormGuard(isDirty, options?)

useFormGuard(isDirty: boolean, options?: Options): Result

useFormGuard(options)

useFormGuard(options: Options): Result

Options

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.

Result

Property Type Description
isBlocked boolean Whether navigation is currently being guarded.

How It Works

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()

Next.js App Router

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 before pushState is intercepted.


Comparison

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

Contributing

Contributions, issues, and feature requests are welcome.
Please check the issues page before submitting.


License

MIT © rl0425

About

Framework-agnostic React hook to prevent accidental navigation with unsaved form changes. Works with Next.js App Router, React Hook Form, Formik, and more.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors