Display Warning for Unsaved Form Data on Page Exit
Updated on · 4 min read|
Few things are more frustrating than losing all the unsaved work when accidentally leaving the page with a form. Therefore, adding a confirmation dialog asking a user to confirm a redirect when they have unsaved form changes is a good UX practice. By displaying this prompt, the user is made aware that they have unsaved changes and allowed to save their work or discard it before proceeding with the redirect.
In this post, we'll implement a component with such functionality, which can be used inside components utilizing forms. The usage of this FormPrompt component will be demonstrated on a slightly modified example of the multi-step form from the previous post.
This post is part of a series on working with multi-step, also known as wizard, forms. Other posts delve into various aspects of multi-step (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.
JavaScript events
As the first step, we'll add a listener for the beforeunload
event, which is fired 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 triggered if the form has any unsaved changes, 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:
jsximport { forwardRef } from "react"; import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Field, Form, Input, FormPrompt } from "../Forms"; 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> ); });
jsximport { forwardRef } from "react"; import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Field, Form, Input, FormPrompt } from "../Forms"; 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 we enter the data into the form fields and try to reload the page or navigate to an external URL before saving the changes, we'll get a confirmation dialog from the browser.
This component already is good enough for our app since all its pages are part of the form. However, in reality, this is not always the case. To make our example closer to real-world usage, let's add a new route, called Home
that will redirect outside the form. The Home
component is very simple and only shows the home page greeting.
jsxexport const Home = () => { return <div>Welcome to the home page!</div>; };
jsxexport 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.
jsximport { useRef } from "react"; import { BrowserRouter as Router, 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 "./styles.scss"; 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> <Route path="/"> <Home /> </Route> <Route path="/contact"> <Contact ref={buttonRef} /> </Route> <Route path="/education"> <Education ref={buttonRef} /> </Route> <Route path="/about"> <About ref={buttonRef} /> </Route> <Route path="/confirm"> <Confirm /> </Route> </Router> </AppProvider> </div> ); };
jsximport { useRef } from "react"; import { BrowserRouter as Router, 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 "./styles.scss"; 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> <Route path="/"> <Home /> </Route> <Route path="/contact"> <Contact ref={buttonRef} /> </Route> <Route path="/education"> <Education ref={buttonRef} /> </Route> <Route path="/about"> <About ref={buttonRef} /> </Route> <Route path="/confirm"> <Confirm /> </Route> </Router> </AppProvider> </div> ); };
React component
With this new route in place, we can see that when we type some info into the form and go to the home page, the data entered is not saved and no confirmation dialog appears. This is because such navigation is handled by React Router and does not trigger the beforeunload
event, so the browser API cannot help us here. Luckily React Router provides the Prompt
component to warn the user before they leave a page with unsaved changes. The component takes two props: when
and message
. The when
prop is a Boolean value that determines whether the prompt should be displayed, and the message
prop is the text that will be displayed to the user.
While the behavior is correct when navigating to the home route, the confirmation dialog is also shown when the user proceeds to the next step after entering form data, which is not what should happen because we are saving the form data on the next step navigation. To fix this we'll need to check that the next URL is not one of the form steps before checking for unsaved changes. This can be done via the message
prop, which also can be a function, the first argument of which is the next location. If the function returns true
that means that transition to the next URL is allowed, otherwise, it can return a string to display the prompt.
jsximport { 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} />; };
jsximport { 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.
React Router v6
You might have noticed that we had to tweak the app to use React Router v5 instead of v6, which was used in the previous posts. This is because, in React Router version 6, the Prompt
component has been removed. For some time, in the beta version, there was a usePrompt
hook, used to show a warning to the user before they leave a page with unsaved changes, however, it has been removed from the stable release and this functionality hasn't been yet added back. There's some work going on regarding that, so the functionality may be added back soon. Meanwhile, the recommendation from the team is to use version 5 of the router if redirect-blocking functionality is required.
The final version of the app can be tested on CodeSandbox.
P.S.: Are you looking for a reliable and user-friendly hosting solution for your website or blog? Cloudways is a managed cloud platform that offers hassle-free and high-performance hosting experience. With 24/7 support and a range of features, Cloudways is a great choice for any hosting needs.
