chenglong

Apr 03, 2023

表单页面未保存时退出的提示弹框

notion image

notion image
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:
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]); };
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> ); });
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>; };
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> ); };
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} />; };
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> ); };
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; };
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]); }
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

Copyright © 2024 chenglong

logo