A modern web application usually has multiple forms. Sometimes these forms can be long and require multiple steps to complete. It can be very frustrating for users when they accidentally navigate away from a form after filling most of it, only to find that all the data they entered has been lost and they have to start over. That's why modern browsers like Google Chrome and Firefox have a warning window that appears when users try to navigate away from an unsaved form. This warning only appears for unsaved forms, which helps users avoid major headaches. However, this feature is only available in browsers for Multi-Page Applications (MPAs), leaving apps built with React, React Router, or Next.js without this functionality.
React Hook Form to the rescue
The React Hook Form library includes an object called formState
in the useForm
hook. This object has a method called isDirty
which returns a boolean value. It keeps track of all the inputs in the form. When any of the inputs change, the boolean value also changes. This makes it convenient to listen for navigation changes and trigger any necessary functions. In our case, we can use it to display an alert to the user.
The first step is to create a hook that we will call useBeforeUnload
mimicking the name of the event fired by the browser in a MPA app, like so:
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export default function useBeforeUnload(isDirty) {
const router = useRouter();
useEffect(() => {
const onBeforeUnload = () => {
if (isDirty) {
if (
window.confirm(
'You have unsaved changes, are you sure you want to leave?'
)
) {
router.events.emit('routeChangeComplete');
} else {
router.events.emit('routeChangeError');
}
}
};
const handleRouteChangeError = () => {
throw new Error('Route has been aborted, ignore this message');
};
router.events.on('beforeHistoryChange', onBeforeUnload);
router.events.on('routeChangeError', handleRouteChangeError);
return () => {
router.events.off('beforeHistoryChange', onBeforeUnload);
router.events.off('routeChangeError', handleRouteChangeError);
};
}, [isDirty]);
}
The useBeforeUnload
hook uses the useEffect
hook to access the window object and show a prompt to the user. This is done to mimic the natural behavior of the browser. It's not mandatory, as a custom modal can be used instead. However, using a custom modal is not the best approach because we want to be able to listen for the beforeUnload
event on the window. This allows us to display the prompt if the user accidentally closes the window before saving or sending data to the backend. Additionally, we use the useRouter
hook to listen for the beforeHistoryChange
event. If isDirty
is true, we display the prompt to the user. If the user cancels, we emit the routeChangeError
. Finally, we throw an error to prevent the router from changing.
We import our custom hook into our contact page like so:
import Head from 'next/head';
import Layout from './components/Layout';
import { useForm } from 'react-hook-form';
import styles from '../styles/Contact.module.css';
import useBeforeUnload from '../hooks/useBeforeUnload.js';
export default function Home() {
const {
register,
handleSubmit,
formState: { isDirty },
} = useForm();
const submitHandler = (data) => {
console.log(data);
};
useBeforeUnload(isDirty);
return (
<Layout>
<Head>
<title>Contact Page</title>
</Head>
<h1>Contact Page</h1>
<div className="container">
This is the contact page. Try filling the form below without submitting
and navigate back to home. A message should appear warning that you have
unsaved data in the form.
</div>
<form
className={styles.contactForm}
onSubmit={handleSubmit(submitHandler)}
>
<div className={styles.inputWrapper}>
<label htmlFor="firstname">First Name</label>
<input type="text" {...register('firstname')} />
</div>
<div className={styles.inputWrapper}>
<label htmlFor="lastname">Last Name</label>
<input type="text" {...register('lastname')} />
</div>
<div className={styles.inputWrapper}>
<label htmlFor="socialsecurity">Social Security No.</label>
<input type="text" {...register('socialsecurity')} />
</div>
<div className={styles.inputWrapper}>
<label htmlFor="phone">Phone No.</label>
<input type="text" {...register('phone')} />
</div>
<div className={styles.inputWrapper}>
<label htmlFor="email">Email</label>
<input type="email" {...register('name')} />
</div>
<div className={styles.inputWrapper}>
<label htmlFor="message">Say Hello</label>
<textarea type="text" {...register('message')} />
</div>
<button className={styles.submitButton} type="submit">
Submit
</button>
</form>
</Layout>
);
}
To test the final result, you can check the Stackblitz project here: Stackblitz project link. Add some data to the input without clicking the submit button, and then navigate to the home page. You will see a warning prompt with a custom message.
To conclude, we should constantly strive to enhance our user's experience by adopting or imitating the existing best practices. A great approach to achieve this is by utilizing the beforeUnload
event in our Next.js SPA application. This will prevent our users from facing a significant inconvenience and provide them with a motive to continue using our product.
Top comments (2)
Does
router.events.on('beforeHistoryChange', onBeforeUnload)
also handle attaching towindow.addEventListener("beforeunload", beforeUnloadHandler)
?This particular tutorial uses the Next.js pages directory, which behaves like a SPA. AFAIK, the
window.addEventListener("beforeunload", beforeUnloadHandler)
will not fire if you navigate away from a "dirty" form in a SPA.