Form data loss prevention in React

My days have been dominated by forms for the last 2 years, approximately. As an engineer on for a mortgage origination company, that shouldn’t surprise anybody. Ultimately, the products I work on revolve around a single government form called “Form 1003.” It consists of hundreds of fields of varying inputs. From borrower personal information, property information, loan information, and more.

One of the core problems we want to solve for our users is ensuring a proper balance between expected behavior and data loss prevention. We’ve floated the idea of automatic saving, but that overwrites data and does so without the user necessarily intending to do so.

For now, our minimum bar is to alert the user of potentially data-destructive actions that they are taking. Our main focus is on navigation & modal dismissal.

In identifying potential actions users can take that would result in data loss, we came up with 5 distinct parts:

  • Dirty form tracking / convenience methods
  • Confirmation modal
  • Router navigation logic (history push/pop)
  • Browser navigation (refresh/URL change)
  • Destructive action safeguard

Dirty Form Tracking, Convenience Methods

The basis for the remaining 4 parts of the solution are constructed on top of this one. At a high level, we have a useReducer hook that has a few actions to listen for: action attempted, navigation attempted, and form state change.

Using these actions, we can track which forms are dirty (via some unique ID), when an action was attempted (and what type, navigation vs ‘other destructive’), and when the user has confirmed the action. The convenience methods around this state are to dispatch the actions (useful abstraction), and the derived concepts to be used in hooks down the line (for instance, canNavigate is comprised of the set of dirty forms being empty & the action being a navigation action).

Our resulting object from this hook is simply an object reference with all of the properties discussed above, to then be set as the context value for the application. We set up a singleton context to ensure that all child forms are reporting to the same reducer.

Form registration is a simple hook consisting of two use effect hooks:

  • Dirty state change
  • Form submission

In the event of dirty state changing for a particular form, we add or remove the ID associated with that form from the set of dirtied forms. This is effectively tracking the entire form in the context value.

Next, we listen, in the case of React Hook Form, for the submitCount to change on the form state. This signals to the reducer that we need to remove it from the dirty state tracking, as the changes have been submitted to the API, and is no longer in need of confirming data loss. One thing to note: submit count is less effective than dirty state, at least in RHF. In the event of the form being submitted, the form is still deemed dirty within form state. Subsequent changes, therefore do not “re-register” it as being dirty.

Confirmation Modal

The confirmation modal itself will be very specific to your user experience/design system, but we set up a modal element with 3 properties derived from the reducer: isOpen (if an action was attempted and needing confirmed/canceled), onCancel, and onConfirm.

This modal is responsible for presenting the confirmation messaging to the user, as well as calling the appropriate actions to update the reducer state. On cancel results in the last attempted action being cleared from the state. On confirm is used to then invoke the attempted action and reset the dirty form state. This is, in effect, starting the store over from scratch.

Router/Browser Navigation

Next is the router navigation hook & elements. This is ultimately a hook that produces a getUserConfirmation callback to then be tied into React Router’s equivalent property. One important call out: getUserConfirmation cannot function without a Prompt element as a child of the Router. Our element looks as follows:

({ children }) => (
<HashRouter getUserConfirmation={useBrowserNavigationSafeguard(CONFIRMATION_TEXT)}>
<Prompt message={CONFIRMATION_TEXT} />
{children}
</HashRouter>
);

As you can see, we set the result of the useBrowserNavigationSafeguard as the user confirmation callback for HashRouter. Another important note: the router configured should be the lowest one in the tree. I found that we had several unnecessary routers while implementing this feature that intercepted the route change.

Within the useBrowserNavigationSafeguard hook we set up two useEffect hooks. One is to listen for Router navigation events that are pushed to the reducer as attempted navigation actions, and the other is to listen for beforeunload events on the browser. The way to block navigation is to set the returnValue property on the event based on dirty forms existing in the reducer.

Destructive Action Safeguard

Last, we have our destructive action safeguard. This is a function to be invoked by the form and returns a callback. That callback is effectively tied into the context discussed in part 1. In the event of the callback being invoked, and the form ID does not exist in the set of dirty forms, then it is safe to perform that action. However, if the ID registered to the action is indeed dirty, then we display the confirmation modal to the user. On confirmation, invoke the callback. On cancellation of the action, do nothing.

We have used this successfully with a multitude of actions, such as closing modals, running a related action elsewhere in the tree, and (I believe) it can be applied to elements unmounting. This last idea has not been attempted yet, but in a useEffect ‘s cleanup callback, we could effectively register a “handle submit” as the action. If the user confirms, don’t submit the form. If they hit cancel, we can remount the element with the form state that was being removed from the tree.

This approach to data loss prevention, so far, has been rather effective. More importantly, the hook based approach has allowed us to create a level of composition to solve for this problem, and individual parts are going to be leveraged heavily in other features. The dirty state context will be used for visual cues to the user that certain sections have been interacted with, we can determine if there are any changes to enable/disable a global save/submit button, and more.

Front End Developer