Auto-Saving Form Data In The Background Using The fetch() API
In Dig Deep Fitness, my ColdFusion fitness tracker, the main gesture of the app is the "Perform Exercise" view. In this view, the user is presented with a series of inputs for resistance weights, reps, and notes. Depending on how fast a user is moving through their workout, they may be on this one view for several minutes without submitting the form back to the ColdFusion server. This "pending data" makes me nervous. As such, I've started auto-saving the form data in the background using JavaScript's fetch()
API.
Historically, I would have implemented this auto-save functionality by creating an API end-point and then POST
ing the data to the API. However, I decided to take a page from the Hotwire Turbo playbook; and, simply POST
the form data back to the same resource that I would with the normal form submission. This way, I don't (really) have to add any additional form-process logic.
Posting the form data via JavaScript is made almost effortless thanks to the FormData
object. When invoking the FormData()
constructor, we can supply an HTMLFormElement
: new FormData( form )
. And, in doing so, the browser will automatically populate the FormData
instance with all of the valid form-fields that would have been submitted back to the server on a native form submission.
Here is my JavaScript method for executing this background-save operation using the FormData
class and the fetch()
API - assume that I have an existing form
reference variable:
<script type="text/javascript">
// ... truncated code ...
// I submit the form data in the background.
function commitAutoSave() {
// CAUTION: While the form node has PROPERTIES for both "method" and "action",
// Form elements have provide an historic behavior where you can reference
// any input element by name. As such, you will commonly run into an issue
// where in an input elements with the colliding names, "method" and "action"
// (such as our Button). As such, it is a good practice to access the form
// properties as the attributes.
var formMethod = form.getAttribute( "method" );
var formAction = form.getAttribute( "action" );
// The FormData() constructor can be given a form element, and will
// automatically populate the FormData() instance with all of the valid values
// that would normally be submitted along with the native form submission.
var formData = new FormData( form );
fetch(
formAction,
{
method: formMethod,
body: formData
}
).catch(
( transportError ) => {
console.warn( "Fetch API failed to send." );
console.error( transportError );
}
);
}
</script>
There's very little going on here - I'm grabbing the form's method
and action
attributes, I'm collecting the form data via the FormData()
constructor, and then I'm using the fetch()
API to submit the form in the background. Since this is a background-save operation, I don't really care about the response, even if it's an error response. Ultimately, the background-save is a nice-to-have, progressive enhancement that may help to prevent data-loss; but, it's still the responsibility of the user to explicitly submit their form when they are done performing the exercise.
Of course, committing the background-save is only half the problem - the other half is figuring out when to perform the background-save. For that, I am going to listen for the input
event on the form element. The input
event is fired any time a change is made to the value of a form control. And, the input
event bubbles-up in the DOM (Document Object Model). Which means, the form element acts as a natural event-delegation choke-point for all inputs contained therein.
Here's the full JavaScript for this demo (less the ColdFusion code). I've included some debouncing such that I'm not actually triggering a fetch()
call after every key-stroke:
<script type="text/javascript">
var form = document.querySelector( "form" );
var autoSaveTimer = null;
// The "input" event bubbles up in the DOM from every input change. As such, we
// can think of this as a form of event-delegation wherein we only have to listen
// to the one root element instead of each individual inputs.
form.addEventListener( "input", prepareForAutoSave );
// Since we're going to be debouncing the "input" event with a timer, we're going
// to want to cancel any pending background-save timer when the form is explicitly
// submitted by the user.
form.addEventListener( "submit", cancelAutoSave );
// ---
// PUBLIC METHODS.
// ---
// I cancel any pending background-save timer.
function cancelAutoSave() {
clearTimeout( autoSaveTimer );
}
// I setup a pending background-save timer.
function prepareForAutoSave() {
cancelAutoSave();
autoSaveTimer = setTimeout( commitAutoSave, 500 );
}
// I submit the form data in the background.
function commitAutoSave() {
// CAUTION: While the form node has PROPERTIES for both "method" and "action",
// Form elements have provide an historic behavior where you can reference
// any input element by name. As such, you will commonly run into an issue
// where in an input elements with the colliding names, "method" and "action"
// (such as our Button). As such, it is a good practice to access the form
// properties as the attributes.
var formMethod = form.getAttribute( "method" );
var formAction = form.getAttribute( "action" );
// The FormData() constructor can be given a form element, and will
// automatically populate the FormData() instance with all of the valid values
// that would normally be submitted along with the native form submission.
var formData = new FormData( form );
fetch(
formAction,
{
method: formMethod,
body: formData
}
).catch(
( transportError ) => {
console.warn( "Fetch API failed to send." );
console.error( transportError );
}
);
}
</script>
As you can see, we're just building on top of the previous code, this time adding some DOM-event handlers that pipe the user's interactions into the background auto-save workflow.
Typically, when a form is submitted back to the ColdFusion server, the server will process the data and then redirect the user to another page. In the case of the background-save, I don't want the redirect to take place as I'm not actually inspecting the response of the fetch()
API call. As such, the redirect represents an unnecessary request / unnecessary load on the server.
To prevent the redirect from taking place on the server, I'm setting the default action on the form to be the background save implementation. This way, the "full processing" of the form (including the redirect) only happens when the user explicitly submits the form with a different action (ie, the action provided by the form-submission button).
Here's the full ColdFusion code for this demo - note that the form.action
CFParam
tag defaults to backgroundSave
. And, only invokes the CFLocation
tag if the form.action
is some other value:
<cfscript>
// Defaulting the in-memory data structure for the demo.
param name="application.userData" type="struct" default={};
param name="application.userData.name" type="string" default="";
param name="application.userData.description" type="string" default="";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
param name="form.name" type="string" default="";
param name="form.description" type="string" default="";
param name="form.submitted" type="boolean" default=false;
// By default, we're going to assume the action is a background-save. This way, we
// only perform the full save / redirect back to the homepage when the form is
// submitted with the actual submit button.
param name="form.action" type="string" default="backgroundSave";
// Process the form submission.
if ( form.submitted ) {
application.userData = {
name: form.name.trim(),
description: form.description.trim()
};
if ( form.action != "backgroundSave" ) {
location( url = "./index.cfm", addToken = false );
}
// Initialize the form parameters.
} else {
form.name = application.userData.name;
form.description = application.userData.description;
}
</cfscript>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body>
<cfoutput>
<h1>
User Profile
</h1>
<form method="post" action="./edit.cfm">
<input type="hidden" name="submitted" value="true" />
<p>
<strong>Name:</strong><br />
<input
type="text"
name="name"
value="#encodeForHtmlAttribute( form.name )#"
size="40"
/>
</p>
<p>
<strong>Bio:</strong><br />
<input
type="text"
name="description"
value="#encodeForHtmlAttribute( form.description )#"
size="40"
/>
</p>
<p>
<!---
NOTE: The Submit button has the name "action". The value associated
with this button will only be included in the form POST if the user
clicks on this button (or hits Enter in a field that precedes the
button in DOM-order).
--->
<button type="submit" name="action" value="save">
Save
</button>
</p>
<p>
<a href="./index.cfm">Back to home</a>
</p>
</form>
</cfoutput>
<script type="text/javascript">
var form = document.querySelector( "form" );
var autoSaveTimer = null;
// The "input" event bubbles up in the DOM from every input change. As such, we
// can think of this as a form of event-delegation wherein we only have to listen
// to the one root element instead of each individual inputs.
form.addEventListener( "input", prepareForAutoSave );
// Since we're going to be debouncing the "input" event with a timer, we're going
// to want to cancel any pending background-save timer when the form is explicitly
// submitted by the user.
form.addEventListener( "submit", cancelAutoSave );
// ---
// PUBLIC METHODS.
// ---
// I cancel any pending background-save timer.
function cancelAutoSave() {
clearTimeout( autoSaveTimer );
}
// I setup a pending background-save timer.
function prepareForAutoSave() {
cancelAutoSave();
autoSaveTimer = setTimeout( commitAutoSave, 500 );
}
// I submit the form data in the background.
function commitAutoSave() {
// CAUTION: While the form node has PROPERTIES for both "method" and "action",
// Form elements have provide an historic behavior where you can reference
// any input element by name. As such, you will commonly run into an issue
// where in an input elements with the colliding names, "method" and "action"
// (such as our Button). As such, it is a good practice to access the form
// properties as the attributes.
var formMethod = form.getAttribute( "method" );
var formAction = form.getAttribute( "action" );
// The FormData() constructor can be given a form element, and will
// automatically populate the FormData() instance with all of the valid values
// that would normally be submitted along with the native form submission.
var formData = new FormData( form );
fetch(
formAction,
{
method: formMethod,
body: formData
}
).catch(
( transportError ) => {
console.warn( "Fetch API failed to send." );
console.error( transportError );
}
);
}
</script>
</body>
</html>
If I now open this ColdFusion page and start typing, we can see that the background-save is triggering fetch()
API calls while I type. These calls are incrementally updating the persisted user-data with my pending form submission:
As you can see, whenever there is a brief pause in the data-entry, I'm triggering a background fetch()
API request in order to persist the data to the ColdFusion server. This kind of a workflow doesn't make sense all the time. But, in this particular case - where I want to prevent possible data-loss - it's going to give me peace-of-mind.
Want to use code from this post? Check out the license.
Reader Comments
Was comparing fetch() to xhr() and jQuery.ajax() functions after seeing this. Fetch, unfortunately, doesn't send or receive any cookies (could be good OR bad), so you'd have to have an alternative session management if being logged in is required.
I think. I read it all really fast!
@Will,
The
fetch()
API will definitely post cookies along with the request. There may be a way to turn that off - I'm not sure. But, it certainly posts them by default.And, to be clear, I'm not trying to say that
fetch()
is any better thanjQuery.ajax()
- or any other transport. It just happens to be native to the browser runtime now, so I was able to use it in a place where I didn't have any other library available to me.Personally, I think the Developer experience of the
fetch()
is not great. Things likejQuery.ajax()
feel more dev-friendly. Normally, when I use thefetch()
API, I'll actually wrap it in something else so that I can hide-away the low-level bits that I don't really care about.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →