In this tutorial, we will write a tiny JavaScript event handler that will post our HTML forms using fetch
instead of the classic synchronous redirect form post. We're building a solution based on the Progressive Enhancement strategy, if JavaScript fails to load, users will still be able to submit our forms but if JavaScript is available the form submit will be a lot more smooth. While building this solution we'll explore JavaScript DOM APIs, handy HTML structures, and accessibility related topics.
Let's start by setting up a form.
This article was originally published on my personal blog
Setting up the HTML
Let's build a newsletter subscription form.
Our form will have an optional name field and an email field that we'll mark as required. We assign the required
attribute to our email field so the form can't be posted if this field is empty. Also, we set the field type to email
which triggers email validation and shows a nice email keyboard layout on mobile devices.
<form action="subscribe.php" method="POST">
Name
<input type="text" name="name"/>
Email
<input type="email" name="email" required/>
<button type="submit">Submit</button>
</form>
Our form will post to a subscribe.php
page, which in our situation is nothing more than a page with a paragraph that confirms to the user that she has subscribed to the newsletter.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Successfully subscribed!</title>
</head>
<body>
<p>Successfully subscribed!</p>
</body>
</html>
Let's quickly move back to our <form>
tag to make some tiny improvements.
If our stylesheet somehow fails to load it currently renders like this:
This isn't horribly bad for our tiny form, but imagine this being a bigger form, and it'll be quite messy as every field will be on the same line. Let's wrap each label and field combo in a <div>
.
<form action="subscribe.php" method="POST">
<div>
Name
<input type="text" name="name"/>
</div>
<div>
Email
<input type="email" name="email" required/>
</div>
<button type="submit">Submit</button>
</form>
Now each field is rendered on a new line.
Another improvement would be to wrap the field names in a <label>
element so we can explicitly link each label to its sibling input field. This allows users to click on the label to focus the field but also triggers assistive technology like screen readers to read out the label of the field when the field receives focus.
<form action="subscribe.php" method="POST">
<div>
<label for="name">Name</label>
<input type="text" name="name" id="name"/>
</div>
<div>
<label for="email">Email</label>
<input type="email" name="email" id="email" required/>
</div>
<button type="submit">Submit</button>
</form>
A tiny effort resulting in big UX and accessibility gains. Wonderful!
With our form finished, let's write some JavaScript.
Writing the Form Submit Handler
We'll write a script that turns all forms on the page into asynchronous forms.
We don't need access to all forms on the page to set this up, we can simply listen to the 'submit'
event on the document
and handle all form posts in a single event handler. The event target will always be the form that was submitted so we can access the form element using e.target
To prevent the classic form submit from happening we can use the preventDefault
method on the event
object, this will prevent default actions performed by the browser.
If you only want to handle a single form, you can do so by attaching the event listener to that specific form element.
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// Prevent the default form submit
e.preventDefault();
});
Okay, we're now ready to send our form data.
This action is two-part, the sending part and the data part.
For sending the data we can use the fetch
API, for gathering the form data we can use a super handy API called FormData
.
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// Post data using the Fetch API
fetch(form.action, {
method: form.method,
body: new FormData(form)
})
// Prevent the default form submit
e.preventDefault();
});
Yes, I kid you not, it's this straightforward.
The first argument to fetch
is a URL, so we pass the form.action
property, which contains subscribe.php
. Then we pass a configuration object, which contains the method
to use, which we get from the form.method
property (POST
). Lastly, we need to pass the data in the body
property. We can blatantly pass the form
element as a parameter to the FormData
constructor and it'll create an object for us that resembles the classic form post and is posted as multipart/form-data
.
Michael Scharnagl suggested moving the preventDefault()
call to the end, this makes sure the classic submit is only prevented if all our JavaScript runs.
We're done! To the pub!
Of course, there are a couple of things we forgot, this basically was the extremely happy flow, so hold those horses and put down that pint. How do we handle connection errors? What about notifying the user of a successful subscription? And what happens while the subscribe page is being requested?
The Edge Cases
Let's first handle notifying the user of a successful newsletter subscription.
Showing the Success State
We can do this by pulling in the message on the subscribe.php page and showing that instead of the form element. Let's continue right after the fetch
statement and handle the resolve case of the fetch
call.
First, we need to turn the response into a text
based response. Then we can turn this text-based response in an actual HTML document using the DOMParser
API, we tell it to parse our text and regard it as text/html
, we return this result so it's available in the next then
Now we have an HTML document to work with (doc
) we can finally replace our form with the success status. We'll copy the body.innerHTML
to our result.innerHTML
, then we replace our form with the newly created result element. Last but not least we move focus to the result element so it's read to screen reader users and keyboard users can resume navigation from that point in the page.
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// Post data using the Fetch API
fetch(form.action, {
method: form.method,
body: new FormData(form)
})
// We turn the response into text as we expect HTML
.then(res => res.text())
// Let's turn it into an HTML document
.then(text => new DOMParser().parseFromString(text, 'text/html'))
// Now we have a document to work with let's replace the <form>
.then(doc => {
// Create result message container and copy HTML from doc
const result = document.createElement('div');
result.innerHTML = doc.body.innerHTML;
// Allow focussing this element with JavaScript
result.tabIndex = -1;
// And replace the form with the response children
form.parentNode.replaceChild(result, form);
// Move focus to the status message
result.focus();
});
// Prevent the default form submit
e.preventDefault();
});
Connection Troubles
If our connection fails the fetch
call will be rejected which we can handle with a catch
First, we extend our HTML form with a message to show when the connection fails, let's place it above the submit button so it's clearly visible when things go wrong.
<form action="subscribe.php" method="POST">
<div>
<label for="name">Name</label>
<input type="text" name="name" id="name"/>
</div>
<div>
<label for="email">Email</label>
<input type="email" name="email" id="email" required/>
</div>
<p role="alert" hidden>Connection failure, please try again.</p>
<button type="submit">Submit</button>
</form>
By using the hidden
attribute, we've hidden the <p>
from everyone. We've added a role="alert"
to the paragraph, this triggers screen readers to read out loud the contents of the paragraph once it becomes visible.
Now let's handle the JavaScript side of things.
The code we put in the fetch
rejection handler (catch
) will select our alert paragraph and show it to the user.
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// Post data using the Fetch API
fetch(form.action, {
method: form.method,
body: new FormData(form)
})
// We turn the response into text as we expect HTML
.then(res => res.text())
// Let's turn it into an HTML document
.then(text => new DOMParser().parseFromString(text, 'text/html'))
// Now we have a document to work with let's replace the <form>
.then(doc => {
// Create result message container and copy HTML from doc
const result = document.createElement('div');
result.innerHTML = doc.body.innerHTML;
// Allow focussing this element with JavaScript
result.tabIndex = -1;
// And replace the form with the response children
form.parentNode.replaceChild(result, form);
// Move focus to the status message
result.focus();
})
.catch(err => {
// Some form of connection failure
form.querySelector('[role=alert]').hidden = false;
});
// Make sure connection failure message is hidden
form.querySelector('[role=alert]').hidden = true;
// Prevent the default form submit
e.preventDefault();
});
We select our alert paragraph with the CSS attribute selector [role=alert]
. No need for a class name. Not saying we might not need one in the future, but sometimes selecting by attribute is fine.
I think we got our edge cases covered, let's polish this up a bit.
Locking Fields While Loading
It would be nice if the form locked all input fields while it's being sent to the server. This prevents the user from clicking the submit button multiple times, and also from editing the fields while waiting for the process to finish.
We can use the form.elements
property to select all form fields and then disable each field.
If you have a <fieldset>
in your form, you can disable the fieldset and that will disable all fields inside it
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// Post data using the Fetch API
fetch(form.action, {
method: form.method,
body: new FormData(form)
})
// We turn the response into text as we expect HTML
.then(res => res.text())
// Let's turn it into an HTML document
.then(text => new DOMParser().parseFromString(text, 'text/html'))
// Now we have a document to work with let's replace the <form>
.then(doc => {
// Create result message container and copy HTML from doc
const result = document.createElement('div');
result.innerHTML = doc.body.innerHTML;
// Allow focussing this element with JavaScript
result.tabIndex = -1;
// And replace the form with the response children
form.parentNode.replaceChild(result, form);
// Move focus to the status message
result.focus();
})
.catch(err => {
// Show error message
form.querySelector('[role=alert]').hidden = false;
});
// Disable all form elements to prevent further input
Array.from(form.elements).forEach(field => field.disabled = true);
// Make sure connection failure message is hidden
form.querySelector('[role=alert]').hidden = true;
// Prevent the default form submit
e.preventDefault();
});
form.elements
needs to be turned into an array using Array.from
for us to loop over it with forEach
and set the disable
attribute on true
for each field.
Now we got ourselves into a sticky situation because if fetch
fails and we end up in our catch
all form fields are disabled and we can no longer use our form. Let's resolve that by adding the same statement to the catch
handler but instead of disabling the fields we'll enable the fields.
.catch(err => {
// Unlock form elements
Array.from(form.elements).forEach(field => field.disabled = false);
// Show error message
form.querySelector('[role=alert]').hidden = false;
});
Believe it or not, we're still not out of the woods. Because we've disabled all elements the browser has moved focus to the <body>
element. If the fetch
fails we end up in the catch
handler, enable our form elements, but the user has already lost her location on the page (this is especially useful for users navigating with a keyboard, or, again, users that have to rely on a screen reader).
We can store the current focussed element document.activeElement
and then restore the focus with element.focus()
later on when we enable all the fields in the catch
handler. While we wait for a response we'll move focus to the form element itself.
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// Post data using the Fetch API
fetch(form.action, {
method: form.method,
body: new FormData(form)
})
// We turn the response into text as we expect HTML
.then(res => res.text())
// Let's turn it into an HTML document
.then(text => new DOMParser().parseFromString(text, 'text/html'))
// Now we have a document to work with let's replace the <form>
.then(doc => {
// Create result message container and copy HTML from doc
const result = document.createElement('div');
result.innerHTML = doc.body.innerHTML;
// Allow focussing this element with JavaScript
result.tabIndex = -1;
// And replace the form with the response children
form.parentNode.replaceChild(result, form);
// Move focus to the status message
result.focus();
})
.catch(err => {
// Unlock form elements
Array.from(form.elements).forEach(field => field.disabled = false);
// Return focus to active element
lastActive.focus();
// Show error message
form.querySelector('[role=alert]').hidden = false;
});
// Before we disable all the fields, remember the last active field
const lastActive = document.activeElement;
// Move focus to form while we wait for a response from the server
form.tabIndex = -1;
form.focus();
// Disable all form elements to prevent further input
Array.from(form.elements).forEach(field => field.disabled = true);
// Make sure connection failure message is hidden
form.querySelector('[role=alert]').hidden = true;
// Prevent the default form submit
e.preventDefault();
});
I admit it's not a few lines of JavaScript, but honestly, there are a lot of comments in there.
Showing a Busy State
To finish up it would be nice to show a busy state so the user knows something is going on.
Please note that while fetch
is fancy, it currently doesn't support setting a timeout and it also doesn't support progress events, so for busy states that might take a while there would be no shame in using XMLHttpRequest
, it would be a good idea even.
With that said the time has come to add a class to that alert message of ours (DAMN YOU PAST ME!). We'll name it status-failure
and add our busy paragraph right next to it.
<form action="subscribe.php" method="POST">
<div>
<label for="name">Name</label>
<input type="text" name="name" id="name"/>
</div>
<div>
<label for="email">Email</label>
<input type="email" name="email" id="email" required/>
</div>
<p role="alert" class="status-failure" hidden>Connection failure, please try again.</p>
<p role="alert" class="status-busy" hidden>Busy sending data, please wait.</p>
<button type="submit">Submit</button>
</form>
We'll reveal the busy state once the form is submitted, and hide it whenever we end up in catch
. When data is submitted correctly the entire form is replaced, so no need to hide it again in the success flow.
When the busy state is revealed, instead of moving focus to the form, we move it to the busy state. This triggers the screen reader to read it out loud so the user knows the form is busy.
We've stored references to the two status messages at the start of the event handler, this makes the code later on a bit easier to read.
document.addEventListener('submit', e => {
// Store reference to form to make later code easier to read
const form = e.target;
// get status message references
const statusBusy = form.querySelector('.status-busy');
const statusFailure = form.querySelector('.status-failure');
// Post data using the Fetch API
fetch(form.action, {
method: form.method,
body: new FormData(form)
})
// We turn the response into text as we expect HTML
.then(res => res.text())
// Let's turn it into an HTML document
.then(text => new DOMParser().parseFromString(text, 'text/html'))
// Now we have a document to work with let's replace the <form>
.then(doc => {
// Create result message container and copy HTML from doc
const result = document.createElement('div');
result.innerHTML = doc.body.innerHTML;
// Allow focussing this element with JavaScript
result.tabIndex = -1;
// And replace the form with the response children
form.parentNode.replaceChild(result, form);
// Move focus to the status message
result.focus();
})
.catch(err => {
// Unlock form elements
Array.from(form.elements).forEach(field => field.disabled = false);
// Return focus to active element
lastActive.focus();
// Hide the busy state
statusBusy.hidden = false;
// Show error message
statusFailure.hidden = false;
});
// Before we disable all the fields, remember the last active field
const lastActive = document.activeElement;
// Show busy state and move focus to it
statusBusy.hidden = false;
statusBusy.tabIndex = -1;
statusBusy.focus();
// Disable all form elements to prevent further input
Array.from(form.elements).forEach(field => field.disabled = true);
// Make sure connection failure message is hidden
statusFailure.hidden = true;
// Prevent the default form submit
e.preventDefault();
});
That's it!
We skipped over the CSS part of front-end development, you can either use a CSS framework or apply your own custom styles. The example as it is should give an excellent starting point for further customization.
One final thing. Don't remove the focus outline.
Conclusion
We've written a semantic HTML structure for our form and then built from there to deliver an asynchronous upload experience using plain JavaScript. We've made sure our form is accessible to users with keyboards and users who rely on assistive technology like screen readers. And because we've followed a Progressive Enhancement strategy the form will still work even if our JavaScript fails.
I hope we've touched upon a couple new APIs and methodologies for you to use, let me know if you have any questions!
Top comments (5)
Awesome write up! I definitely learned some things I'll be implementing into my future form creations. I really appreciate the details and explanations you gave for each line of code. Thank you!
So glad to hear that Mike! Thanks so much for taking the time to write this feedback.
Is there a reason you're not using async/await in 2019? I think the title of this article is slightly misleading.
Honestly, I haven’t gotten around to using it enough for me to feel comfortable advising others to use it.
I wonder if the prevent default call would still run while the fetch request has started, I guess not as async/await makes it synchronous so in that sense I suspect without can be better in this case.
I understand what you mean about the title being misleading, but without the edge cases (and all the comments) it truly is only a couple (okay not two) lines of JS. 🙃
You could use an II(AA)FE to use
async
-await
: