Async Form Posts With A Couple Lines Of Vanilla JavaScript

In this tutorial we’ll write a tiny JavaScript event handler that will post our HTML forms using fetch instead of the classic synchronous 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 form.

With this approach, when JavaScript is available, the form submit will be a lot smoother. While building this solution we’ll explore JavaScript DOM APIs, handy HTML structures, and accessibility related topics.

Let’s get started and set up our form.

Setting up the HTML

Our goal is to build a newsletter subscription form.

The 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:

Name Email

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.

Name
Email

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!

I share web dev tips on Twitter, if you found this interesting and want to learn more, follow me there

Or join my newsletter

More articles More articles