How to Prevent Unintentional Data Loss in Web Forms

 November 27, 2020
  Casey Woolfolk
SHARE ON

Software

Making it easy for users to destroy their work in a single click violates one of the most basic usability principles, which is to respect and protect the user’s work at almost any cost.
Top 10 Application-Design Mistakes of 2008. Nielsen Norman Group.

Mistakes happen. Discarding a user’s form data when he or she attempts to navigate away from the page—perhaps accidentally—can lead to frustrated users, lower form response rates, and abandoned carts.

Many web forms prompt for confirmation before accepting requests to navigate away from the form. These warnings improve the application’s user experience. Unfortunately, they are not trivial to implement. Single-page applications, in particular, must handle navigation within the application as well as external navigation.

This article implements a web form navigation confirmation using TypeScript and React. Traditional web applications are covered first. This solution is then extended to work for single-page applications.

Preventing form data loss in traditional multi-page web applications 🔗

In a traditional web application, users interact with the server by requesting and receiving wholly-formed HTML documents. The user may request and receive other resources such as images or JavaScript files, but each discrete interaction between client and server will result in an HTML document that can be processed and rendered by the browser.

This model of navigation is relatively straightforward:

  • Resources are offered to the user via URLs.
  • Navigating to a new URL will unload the current document and replace it with a new document from the server.
  • The current document’s URL can be tracked by the browser and displayed to the user via the address bar.

DOM events 🔗

Requesting and rendering documents from the server is not the only function of the web browser. Even though client/server interactions result in the loading of a new page in traditional web applications, client-side scripting is still often employed to improve the UI/UX associated with displaying the server’s data or facilitating the user’s next request.

Modern browsers allow client-side scripts to track and react to many actions that occur within the context of the web page via DOM events. Common examples include reacting to user mouse clicks or keypresses, but DOM events also exist for actions handled by the browser itself such as loading or unloading a document, monitoring the user’s connection, etc. As of November 2020, MDN lists 191 events in the “Standard” category representing events defined in official Web specifications which should be supported by standards-compliant browsers.

The beforeunload event 🔗

The beforeunload event is particularly useful for preventing form data loss. As the name would imply, this event is fired before the current page is unloaded. It prompts the user, who is then able to cancel or confirm the navigation.

At first glance, this is a perfect solution. An event handler can be implemented that checks if the user has provided input. If so, it can prompt for confirmation before unloading the page. However, there are a few caveats.

Most DOM event handlers are flexible. With the right JavaScript, clicking a button could submit a form, change the background image, or navigate to an entirely different page. The beforeunload event is much more restrictive. While a developer could place arbitrary code within the handler, the user will be given a generic confirmation box regardless of the additional code.

This is for good reason! One could imagine a nefarious developer blocking all attempts to navigate away from a page if the event allowed more flexibility.

Earlier browsers limited the outcome to a dialog box but allowed custom text within that dialog. Even this presents a risk. The aforementioned developer could place misleading or annoying text in the confirmation.

Your account is currently being upgraded. Navigating away from the site before the upgrade is finished could permanently disable your access! Click “OK” to risk breaking your account.

It looks like you are leaving. Our service is best in class, so this must be a mistake!!!

A particularly devious developer could even word the confirmation so that the user believes that the confirm/cancel operations are inverted, leading the user to cancel the navigation despite wanting to proceed.

The browser may even choose not to display the confirmation dialog at all, particularly if the user does not appear to have interacted with the page. This bias to conservatism is specifically encouraged by the HTML standard.1

As a final word of caution, setting this event listener currently renders the page “unsalvageable” meaning that it will likely not be reused if the user navigates back during the same session. This may be changing soon and is likely not a meaningful concern for most applications.

Despite these minor inconveniences to the developer, the event is widely supported, and the restrictions likely make the web a much more user-friendly place than it would be without them.

Browser support 🔗

Although the event has broad browser support, there are minor differences in the interface it exposes. According to the specification, invoking the event’s preventDefault method is both necessary and sufficient to indicate that the page desires to show a confirmation. Most browsers choose to ignore this guideline. Alternative approaches include returning a string and assigning a string to the event’s returnValue property. As a practical matter, it is best to implement all three approaches to improve browser coverage.

Implementing the useUnloadConfirmation hook 🔗

Having made it through all the background, we can finally implement the hook.

import { useEffect } from 'react'

const handleEvent = (e: BeforeUnloadEvent) => {
    e.preventDefault()

    // This custom message will only be used in older browsers
    const message = "Your changes may not be saved."

    e.returnValue = message
    return message
}

export const useUnloadConfirmation = (shouldPrompt: boolean) => {
    const eventName = 'beforeunload'
    useEffect(() => {
        if (shouldPrompt) {
            window.addEventListener(eventName, handleEvent)
            return () => {
                window.removeEventListener(eventName, handleEvent)
            }
        }
    }, [shouldPrompt])
}

The useUnloadConfirmation hook accepts a single parameter indicating if the confirmation should be shown on navigation. This parameter will typically represent whether the form is “dirty” or not. The hook runs an internal useEffect whenever the value of shouldPrompt changes. This useEffect hook adds a handler that runs on beforeunload events. It returns a clean-up function that removes the listener when useEffect runs next or the component unmounts.

Consuming the custom hook is as simple as adding a line to each relevant form component.

const { formIsDirty } = FormLibraryOfChoice()
useUnloadConfirmation(formIsDirty)

Single-page application navigation 🔗

The useUnloadConfirmation hook provides a lot of functionality in a small amount of code, but it makes an important assumption: navigation that could cause data loss is preceded by a page unload event.

This assumption tends to hold for traditional web applications. Single-page applications (SPAs), however, were created specifically to violate this assumption.

SPAs create the illusion of navigation—changing the URL and title, displaying new content, etc.—without prompting the page to reload. Without an unload event ever firing, it should be fairly obvious that useUnloadConfirmation will not be effective.

Instead of loading separate HTML documents to retrieve and display content, SPAs load an initial document which is then incrementally updated using client-side scripting combined with AJAX requests. “Navigation” within this single, ever-evolving document is typically handled via the History API.

Common browser elements and their method of navigation in single-page applications
Figure 1: In a single-page application, navigation can occur via a page unload/load cycle or the History API.

The History API 🔗

Web browsers keep an ongoing list of addresses visited by the user during the current session. This list allows users to traverse their browsing history using the browser’s “back” and “forward” buttons.

The History API provides a mechanism to view and mutate this list (with restrictions). As a simple example, an SPA could implement a navigation bar with several “links.” Clicking these links does not navigate to a separate document. Instead, it fires a click DOM event which notifies the page’s JavaScript to display new content and uses the History API to modify the URL shown in the address bar.

Conditionally blocking SPA navigation 🔗

Since any calls to the History API are handled by the page’s JavaScript, which also typically handles determining when navigation should be blocked, there is a conceptually straightforward approach to preventing form data loss: wrap all calls to the History API in code that checks if the page has indicated navigation should be blocked.

A React Router example 🔗

Many developers will use a specialized library to handle SPA navigation rather than working directly with the History API. The examples below use React Router and, specifically, its history package. Fortunately, there are many similarities between history and similar functionalities in other JavaScript frameworks such as Vue and Angular.

The history module exposes a simple interface for blocking in-app navigation from within a component.

import { useHistory } from 'react-router'

// ...

const history = useHistory()
// React Router v4.10.1; note that this interface changes after v4
const unblock = history.block('Are you sure you want to leave?')

// Once the component is done blocking
// unblock()

Since it uses the History API, history.block will block in-app navigation, but it cannot prevent the user from navigating to a different external site or reloading the page. This functionality is the exact inverse of useUnloadConfirmation, so one solution would be to include block and unblock in the useEffect hook as was done with addEventListener and removeEventListener.

React Router provides an alternative approach that is more declarative and requires less manual tracking. The Prompt component accepts a boolean when prop which specifies if navigation should be blocked. If the form’s status is already known, Prompt can be a simple solution.

return (
  <Prompt when={formIsDirty} />
  {/* ... */}

Prompt can also be rendered conditionally without providing the when prop.

return (
  {formIsDirty && <Prompt />}
  {/* ... */}

As of the time of writing, the upcoming React Router v6 includes a usePrompt hook which could be used instead of the Prompt component. This hook even handles the beforeunload event listener logic. It should be a great solution for those using React Router.

A more robust solution for single-page applications 🔗

Together, useUnloadConfirmation and Prompt represent a declarative, feature-complete solution. This combination will cover navigation within the application as well as navigation that utilizes a full-page load/unload cycle.

import React from 'react'
import { Prompt } from 'react-router'
import useUnloadConfirmation from './myapp/useUnloadConfirmation'

const EnhancedPrompt: React.FC<{ shouldPrompt: boolean }> = ({ shouldPrompt }) => {
  useUnloadConfirmation(shouldPrompt)

  return (
      <Prompt
        when={shouldPrompt}
        message="Are you sure you want to proceed?"
      />
  )
}

export default EnhancedPrompt

The new EnhancedPrompt component can then be rendered similarly to the earlier Prompt example.

return (
  <EnhancedPrompt shouldPrompt={formIsDirty} />
  {/* ... */}

By preventing users from accidentally destroying their data, regardless of how the navigation is handled internally, a solution like EnhancedPrompt can lead to happier users, product owners, and developers.

Footnotes 🔗

1 “The user agent is encouraged to avoid asking the user for confirmation if it judges that doing so would be annoying, deceptive, or pointless. A simple heuristic might be that if the user has not interacted with the document, the user agent would not ask for confirmation before unloading it.” HTML Standard

SHARE ON
×

Get To Market Faster

Monthly Medtech Insider Insights

Our monthly Medtech tips will help you get safe and effective Medtech software on the market faster. We cover regulatory process, AI/ML, software, cybersecurity, interoperability and more.