Display Warning for Unsaved Form Data on Page Exit
Updated on · 6 min read|In today's digital landscape, providing an optimal user experience is important for web applications that involve form submissions. One common source of frustration for users is losing unsaved changes due to accidental navigation away from the page.
This article will demonstrate how to implement a FormPrompt
component that alerts users when they attempt to leave a page with unsaved changes, effectively enhancing the overall user experience. We will discuss handling such scenarios using pure JavaScript with the beforeunload event, as well as React-specific solutions using the Prompt
component in React Router v5 and the useBeforeUnload
and unstable_useBlocker
hooks in React Router v6.
We will demonstrate the use of this FormPrompt
component in a slightly modified example of the multistep form from the previous post.
The final version of the app can be tested on CodeSandbox and the code is available on GitHub.
This post is part of a series on working with multistep, also known as wizard, forms. Other posts explore various aspects of multistep (wizard) forms:
-
Basic setup for the form and step navigation: Build a Multistep Form With React Hook Form.
-
Saving form data during step navigation and displaying the state of each step in the Stepper component: Advanced Multistep Forms with React Hook Form.
If you're interested in the basics of working with React Hook Form, you may find this post helpful: Managing Forms with React Hook Form.
Detecting leaving the page with the beforeunload event
Let's create the FormPrompt
component where we'll add a listener for the beforeunload
event. This event will fire before a user leaves a page. By calling the preventDefault
method on the event, we can trigger the browser's confirmation dialog. This will only be activated if the form has unsaved changes, as indicated by the hasUnsavedChanges
prop.
jsx// FormPrompt.js import { useEffect } from "react"; export const FormPrompt = ({ hasUnsavedChanges }) => { useEffect(() => { const onBeforeUnload = (e) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", onBeforeUnload); return () => { window.removeEventListener("beforeunload", onBeforeUnload); }; }, [hasUnsavedChanges]); };
jsx// FormPrompt.js import { useEffect } from "react"; export const FormPrompt = ({ hasUnsavedChanges }) => { useEffect(() => { const onBeforeUnload = (e) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", onBeforeUnload); return () => { window.removeEventListener("beforeunload", onBeforeUnload); }; }, [hasUnsavedChanges]); };
As an example, we'll use this component inside the Contact
step from the form:
jsx// Steps/Contact.js import { forwardRef } from "react"; import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; import { FormPrompt } from "../FormPrompt"; export const Contact = forwardRef((props, ref) => { const [state, setState] = useAppState(); const { handleSubmit, register, formState: { isDirty }, } = useForm({ defaultValues: state, mode: "onSubmit", }); const saveData = (data) => { setState({ ...state, ...data }); }; return ( <Form onSubmit={handleSubmit(saveData)} nextStep={"/education"}> <FormPrompt hasUnsavedChanges={isDirty} /> <fieldset> <legend>Contact</legend> <Field label="First name"> <Input {...register("firstName")} id="first-name" /> </Field> <Field label="Last name"> <Input {...register("lastName")} id="last-name" /> </Field> <Field label="Email"> <Input {...register("email")} type="email" id="email" /> </Field> <Field label="Password"> <Input {...register("password")} type="password" id="password" /> </Field> <Button ref={ref}>Next {">"}</Button> </fieldset> </Form> ); });
jsx// Steps/Contact.js import { forwardRef } from "react"; import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; import { FormPrompt } from "../FormPrompt"; export const Contact = forwardRef((props, ref) => { const [state, setState] = useAppState(); const { handleSubmit, register, formState: { isDirty }, } = useForm({ defaultValues: state, mode: "onSubmit", }); const saveData = (data) => { setState({ ...state, ...data }); }; return ( <Form onSubmit={handleSubmit(saveData)} nextStep={"/education"}> <FormPrompt hasUnsavedChanges={isDirty} /> <fieldset> <legend>Contact</legend> <Field label="First name"> <Input {...register("firstName")} id="first-name" /> </Field> <Field label="Last name"> <Input {...register("lastName")} id="last-name" /> </Field> <Field label="Email"> <Input {...register("email")} type="email" id="email" /> </Field> <Field label="Password"> <Input {...register("password")} type="password" id="password" /> </Field> <Button ref={ref}>Next {">"}</Button> </fieldset> </Form> ); });
When data is entered into the form fields and an attempt is made to reload the page or navigate to an external URL before saving the changes, a confirmation dialog from the browser will appear.
Preventing page navigation using React Router 5
This component already is good enough for our app, as all its pages are part of the form. However, in real-world scenarios, this may not always be the case. To make our example more representative of actual use cases, let's add a new route called Home
, which will redirect outside the form. The Home
component is simple, displaying only a home page greeting.
jsx// Home.js export const Home = () => { return <div>Welcome to the home page!</div>; };
jsx// Home.js export const Home = () => { return <div>Welcome to the home page!</div>; };
We'll also need to make some adjustments to the App
component to account for this new route.
jsx// App.js import { useRef } from "react"; import { BrowserRouter as Router, Routes, Route, NavLink, } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; import { Stepper } from "./Steps/Stepper"; import { Home } from "./Home"; export const App = () => { const buttonRef = useRef(); const onStepChange = () => { buttonRef.current?.click(); }; return ( <div className="App"> <AppProvider> <Router> <div className="nav-wrapper"> <NavLink to={"/"}>Home</NavLink> <Stepper onStepChange={onStepChange} /> </div> <Routes> <Route path="/" element={<Home />} /> <Route path="/contact" element={<Contact ref={buttonRef} />} /> <Route path="/education" element={<Education ref={buttonRef} />} /> <Route path="/about" element={<About ref={buttonRef} />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
jsx// App.js import { useRef } from "react"; import { BrowserRouter as Router, Routes, Route, NavLink, } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; import { Stepper } from "./Steps/Stepper"; import { Home } from "./Home"; export const App = () => { const buttonRef = useRef(); const onStepChange = () => { buttonRef.current?.click(); }; return ( <div className="App"> <AppProvider> <Router> <div className="nav-wrapper"> <NavLink to={"/"}>Home</NavLink> <Stepper onStepChange={onStepChange} /> </div> <Routes> <Route path="/" element={<Home />} /> <Route path="/contact" element={<Contact ref={buttonRef} />} /> <Route path="/education" element={<Education ref={buttonRef} />} /> <Route path="/about" element={<About ref={buttonRef} />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
With this new route in place, we can see that when we input information into the form and navigate to the home page, the entered data is not saved and no confirmation dialog appears. This occurs because navigation is handled by React Router and does not trigger the beforeunload
event, rendering the browser API ineffective in this case. Fortunately, React Router v5 offers the Prompt
component to warn users before leaving a page with unsaved changes. The component accepts two props: when
and message
. The when
prop is a Boolean value that determines whether the prompt should be displayed, while the message
prop represents the text shown to the user.
When using Prompt
, the behavior is correct when navigating to the home route, however, the confirmation dialog also appears when users proceed to the next step after entering form data. This is undesired, as we save the form data upon navigating to the next step. To resolve this issue, we need to verify that the next URL is not one of the form steps before checking for unsaved changes. This can be accomplished using the message
prop, which can also be a function. The first argument of this function is the next location. If the function returns true
, the transition to the next URL is permitted; otherwise, it can return a string to display the prompt.
jsx// FormPrompt.js import { useEffect } from "react"; import { Prompt } from "react-router-dom"; const stepLinks = ["/contact", "/education", "/about", "/confirm"]; export const FormPrompt = ({ hasUnsavedChanges }) => { useEffect(() => { const onBeforeUnload = (e) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", onBeforeUnload); return () => { window.removeEventListener("beforeunload", onBeforeUnload); }; }, [hasUnsavedChanges]); const onLocationChange = (location) => { if (stepLinks.includes(location.pathname)) { return true; } return "You have unsaved changes, are you sure you want to leave?"; }; return <Prompt when={hasUnsavedChanges} message={onLocationChange} />; };
jsx// FormPrompt.js import { useEffect } from "react"; import { Prompt } from "react-router-dom"; const stepLinks = ["/contact", "/education", "/about", "/confirm"]; export const FormPrompt = ({ hasUnsavedChanges }) => { useEffect(() => { const onBeforeUnload = (e) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", onBeforeUnload); return () => { window.removeEventListener("beforeunload", onBeforeUnload); }; }, [hasUnsavedChanges]); const onLocationChange = (location) => { if (stepLinks.includes(location.pathname)) { return true; } return "You have unsaved changes, are you sure you want to leave?"; }; return <Prompt when={hasUnsavedChanges} message={onLocationChange} />; };
With these changes, we can safely navigate between the form steps and will receive a warning if we try to leave the form with any unsaved changes.
Preventing page navigation using React Router 6
React Router version 6 introduces significant changes compared to the previous version, especially regarding the redirect-blocking functionality. In this version, the Prompt
component has been removed, and the unstable_usePrompt
hook was added in version 6.7.0. As the name suggests, the hook's implementation is subject to change and is not yet documented. However, it should work for our use case.
We can use this hook to replicate the behavior of the Prompt
component from version 5, but first, we need to adjust our App
component to use the new data routers because they are required for the unstable_usePrompt
hook to work.
jsx// App.js import { useRef } from "react"; import { createBrowserRouter, RouterProvider, Outlet } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; import { Stepper } from "./Steps/Stepper"; import { Home } from "./Home"; export const App = () => { const buttonRef = useRef(); const onStepChange = () => { buttonRef.current?.click(); }; const router = createBrowserRouter([ { element: ( <> <Stepper onStepChange={onStepChange} /> <Outlet /> </> ), children: [ { path: "/", element: <Home />, }, { path: "/contact", element: <Contact ref={buttonRef} />, }, { path: "/education", element: <Education ref={buttonRef} /> }, { path: "/about", element: <About ref={buttonRef} /> }, { path: "/confirm", element: <Confirm /> }, ], }, ]); return ( <div className="App"> <AppProvider> <RouterProvider router={router} /> </AppProvider> </div> ); };
jsx// App.js import { useRef } from "react"; import { createBrowserRouter, RouterProvider, Outlet } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; import { Stepper } from "./Steps/Stepper"; import { Home } from "./Home"; export const App = () => { const buttonRef = useRef(); const onStepChange = () => { buttonRef.current?.click(); }; const router = createBrowserRouter([ { element: ( <> <Stepper onStepChange={onStepChange} /> <Outlet /> </> ), children: [ { path: "/", element: <Home />, }, { path: "/contact", element: <Contact ref={buttonRef} />, }, { path: "/education", element: <Education ref={buttonRef} /> }, { path: "/about", element: <About ref={buttonRef} /> }, { path: "/confirm", element: <Confirm /> }, ], }, ]); return ( <div className="App"> <AppProvider> <RouterProvider router={router} /> </AppProvider> </div> ); };
We use the createBrowserRouter function to create the router. Note that the Stepper
does not have a separate path, and all other routes are its children. It serves as a layout component, which is rendered on every page. The content of each page is displayed in place of the special Outlet component. To simplify the App
logic, we have also moved the Home nav link inside the Stepper
.
With the setup complete, we can now implement the redirect-blocking functionality. We begin by replacing the onbeforeunload
logic inside the FormPrompt
with the useBeforeUnload hook, introduced in version 6.6.
jsx// FormPrompt.js import { useEffect, useCallback, useRef } from "react"; import { useBeforeUnload } from "react-router-dom"; const stepLinks = ["/contact", "/education", "/about", "/confirm"]; export const FormPrompt = ({ hasUnsavedChanges }) => { useBeforeUnload( useCallback( (event) => { if (hasUnsavedChanges) { event.preventDefault(); event.returnValue = ""; } }, [hasUnsavedChanges], ), { capture: true }, ); return null; };
jsx// FormPrompt.js import { useEffect, useCallback, useRef } from "react"; import { useBeforeUnload } from "react-router-dom"; const stepLinks = ["/contact", "/education", "/about", "/confirm"]; export const FormPrompt = ({ hasUnsavedChanges }) => { useBeforeUnload( useCallback( (event) => { if (hasUnsavedChanges) { event.preventDefault(); event.returnValue = ""; } }, [hasUnsavedChanges], ), { capture: true }, ); return null; };
This change streamlines the logic of our component. Now, we can add a custom usePrompt
hook and utilize it similar to the Prompt
component from version 5.
jsx// FormPrompt.js import { useEffect, useCallback, useRef } from "react"; import { useBeforeUnload, unstable_useBlocker as useBlocker, } from "react-router-dom"; const stepLinks = ["/contact", "/education", "/about", "/confirm"]; export const FormPrompt = ({ hasUnsavedChanges }) => { const onLocationChange = useCallback( ({ nextLocation }) => { if (!stepLinks.includes(nextLocation.pathname) && hasUnsavedChanges) { return !window.confirm( "You have unsaved changes, are you sure you want to leave?", ); } return false; }, [hasUnsavedChanges], ); usePrompt(onLocationChange, hasUnsavedChanges); useBeforeUnload( useCallback( (event) => { if (hasUnsavedChanges) { event.preventDefault(); event.returnValue = ""; } }, [hasUnsavedChanges], ), { capture: true }, ); return null; }; function usePrompt(onLocationChange, hasUnsavedChanges) { const blocker = useBlocker(hasUnsavedChanges ? onLocationChange : false); const prevState = useRef(blocker.state); useEffect(() => { if (blocker.state === "blocked") { blocker.reset(); } prevState.current = blocker.state; }, [blocker]); }
jsx// FormPrompt.js import { useEffect, useCallback, useRef } from "react"; import { useBeforeUnload, unstable_useBlocker as useBlocker, } from "react-router-dom"; const stepLinks = ["/contact", "/education", "/about", "/confirm"]; export const FormPrompt = ({ hasUnsavedChanges }) => { const onLocationChange = useCallback( ({ nextLocation }) => { if (!stepLinks.includes(nextLocation.pathname) && hasUnsavedChanges) { return !window.confirm( "You have unsaved changes, are you sure you want to leave?", ); } return false; }, [hasUnsavedChanges], ); usePrompt(onLocationChange, hasUnsavedChanges); useBeforeUnload( useCallback( (event) => { if (hasUnsavedChanges) { event.preventDefault(); event.returnValue = ""; } }, [hasUnsavedChanges], ), { capture: true }, ); return null; }; function usePrompt(onLocationChange, hasUnsavedChanges) { const blocker = useBlocker(hasUnsavedChanges ? onLocationChange : false); const prevState = useRef(blocker.state); useEffect(() => { if (blocker.state === "blocked") { blocker.reset(); } prevState.current = blocker.state; }, [blocker]); }
The useBlocker
hook accepts either a boolean or a blocker function as its argument, similar to the message
prop in the Prompt
component. One of the arguments of this function is the next location, which we use to determine if the user is leaving our form. If that's the case, we leverage the browser's window.confirm method to display a dialog asking the user to confirm the redirect or cancel it. Finally, we abstract the blocking logic and manage the blocker's state within the usePrompt
hook.
We can test that the FormPrompt
works as expected by navigating to the Contact step, filling in some fields, and clicking on the Home nav item. We'd see a confirmation dialog asking us if we want to leave the page.
Conclusion
In conclusion, implementing a confirmation dialog for unsaved form changes is an essential practice for enhancing user experience. This post demonstrated how to create a FormPrompt
component that warns users when they attempt to leave a page with unsaved changes. We explored how to handle such scenarios with pure JavaScript using the beforeunload
event and in React with the Prompt
component in React Router v5, and the useBeforeUnload
and unstable_useBlocker
hooks in React Router v6. By incorporating this functionality into your forms, you can help users avoid the frustration of losing unsaved work.
References and resources
- Advanced Multistep Forms with React Hook Form
- Build a Multistep Form With React Hook Form
- CodeSandbox for the tutorial
- GitHub repository for the tutorial
- MDN: Window: beforeunload event
- MDN: window.confirm
- Managing Forms with React Hook Form
- React Router v5
- React Router v6
- React Router: Data APIs
- React Router: Outlet component
- React Router: createBrowserRouter
- React Router: useBeforeUnload hook