Display Warning for Unsaved Form Data on Page Exit

webdev react forms

Image credit: Microsoft Edge on Unsplash

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.

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.

// 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:

import { 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.

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.

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

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 or not 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.

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.

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.