Progressively Enhancing Form Submissions with Web Components

Simon MacDonald’s avatar

by Simon MacDonald

cat Photo by Pacto Visual on Unsplash

Last week I spoke in London at Full Stack eXchange on functional web apps. One of the pillars of my talk was that our web applications should work without JavaScript. Does this mean I hate JavaScript? Of course not. JavaScript has been very, very good to me. I mean that we should build our apps to work without JavaScript first, then progressively enhance them to improve the user experience.

Since there were a few questions surrounding this approach, I thought I would share the pattern I use when submitting form data from the browser. If you are too impatient to read the entire post, you can always check out the source code for the sample application.

The Application Idea

It is said that you are either a dog person or a cat person. For this post, I’m going to be a cat person as The Cat API provides an endpoint where you can request a random picture of a cat without having to authenticate with the API.

A typical result when querying The Cat API looks like this:

   "id": "rCpfoEpQY",
   "url": "",
   "width": 1400,
   "height": 788

Our app will include a public folder where we will put our static assets like our submit button web component. A src folder where our two routes, one to generate the list view of our cat pics and a second route to request a new cat picture. Finally, we will save the URLs to these pictures in a database table called, you guessed it, cats.

# app.arc

src public

get /cats
post /cats

 id *String

Requesting a Cat Picture

Our post /cats route will be responsible for:

Fetching a new cat picture from The Cat API

let res = await tiny.get({
  url: ''
let cat = res.body[0]

Persisting the picture URL in our database.

const db = await arc.tables()
await db.cats.put(cat)

Responding to the request with JSON when the content-type is application/json or a redirect when the content-type is application/x-www-form-urlencoded.

if (isJSON(req)) {
  return {
    json: cat
} else {
  return {
    location: '/cats'

The only ✨magic✨ here is checking the content-type header to respond correctly to the request. When JavaScript is enabled on the client, we will use the fetch API to request JSON, but if JavaScript is not enabled or has failed to load, we’ll fall back to using a form post.

Link to full source code of src/post-cats/index.mjs.

Viewing our Cats

The get /cats route is fairly straightforward as it will fetch the current cat pics from the database.

const db = await arc.tables()
let result = await db.cats.scan()
let cats = result.Items

Then send HTML down the wire that is immediately usable without any JavaScript.

return {
  html: `<html>
    <script src="./el-submit.js" type="module"></script>
      <form method="POST" action="/cats">
          <button is="el-submit" eventname="new-cat">Random Cat Picture</button>
        ${ => `<li><img src="${cat.url}" width="300"/></li>`).join('')}
      <script src="./index.js"></script>

Link to full source code of src/get-cats/index.mjs.

While the HTML is server-side generated, it includes links to two JavaScript files that we’ll use to enhance the user experience.



Let me reiterate. I love JavaScript. I write JavaScript code almost every day.

Our Submit Button

In the markup coming down from our server, I want to draw your attention to two lines:

<script src="./el-submit.js" type="module"></script>

In the first line, we load our web component definition from the el-submit.js file, and in the second line, we use the component.

Since we want to take advantage of the default form behavior submission, we’ll pass a button component into our web component, which will be used when JavaScript is disabled.

<el-submit eventname="new-cat">
  <button>Random Cat Picture</button>

Why didn’t you use the is property

<button is="el-submit" eventname="new-cat">
  Random Cat Picture

Customized built in elements are supported in Chrome, Edge and Firefox but not in Safari (see WebKit bug 182671). Is Apple purposefully holding the web back? Maybe? Folks really seem to think so.

It’s worth noting that if you use an earlier commit of the demo app which uses the is property. THE APP STILL WORKS IN SAFARI! Because we built it with progressive enhancement in mind.

Now let’s dig into the source of the web component, starting with the constructor.

constructor () {
  this.eventname = this.attributes.eventname.value || 'new-data'
  this.submitForm = this.submitForm.bind(this)
  this.addEventListener('click', this.submitForm)

In the first line of a web components constructor, you should always call super() to inherit the behavior of the element it extends. Then we are pulling off the eventname passed into the web component as an attribute on the button element. Finally, we’ll add an event listener so that our submitForm function is called when our button is clicked.

Here’s an annotated listing of the submitForm method:

  1. Check to make sure that the fetch API exists.
  2. Prevent the default form submission behavior
  3. Find the closest form element to our button.
  4. Convert the form body into a JSON string.
  5. Fetch an update from the form action attribute
  6. Convert the response to JSON
  7. Dispatch an event with the new data from the server.
submitForm (e) {
  if ("fetch" in window) { // 1
      e.preventDefault() // 2
      let form = this.closest('form') // 3
      let body =
        JSON.stringify(Object.fromEntries(new FormData(form))) // 4
      fetch(form.action, { // 5
          method: form.method,
          headers: {
              "Content-Type": "application/json"
      .then(response => response.json()) // 6
      .then(data => {
          const event = new CustomEvent(this.eventname,
              { bubbles: true, detail: data });
          form.dispatchEvent(event) // 7
      .catch(error => {
        const event = new CustomEvent(this.eventname,
            { bubbles: true, detail: error });

All that is left is to add an event listener to update the page with the updated data. A short example that adds a new cat picture to our list would look like this:

function addCat(event) {
   const ul = document.querySelector("ul")
   const li = document.createElement("li")
   const img = document.createElement("img")
   img.src = event.detail.url
   img.width = 300

document.addEventListener('new-cat', addCat)

What I like about this approach, besides reverting to default form submission behavior when there is no JavaScript, is that you can re-use this component as nothing is hard coded. You can style the button with CSS like you normally would and have multiple buttons on the same page to interact with different forms emitting events that your store can react to.

The Demo

In the demo below, you will see the first two button clicks result in a fetch request being sent to our backend API. The third and fourth button clicks don’t have the benefit of JavaScript being enabled, so they revert to the default form post behavior, which results in a page refresh.


You can play with the demo on your local machine by running the following commands:

git clone [](
cd submit-fallback
npm install
npm start

Then open a browser tab to http://localhost:3333/cats to grab some random cat pics. Open the network tab of your browser’s developer tools and play around with disabling JavaScript (instructions for Chrome, Edge, Firefox, Safari) to see how the app responds in the different scenarios.

In Conclusion

Hopefully, you’ve seen that this approach is not as much extra work as you may have initially thought. I love this approach as it forces me to build a base level of functionality that works for all browsers. Some studies show that 0.2% of web browsers have JavaScript disabled. That may not seem like a huge number, but if you care about accessibility and folks using assistive technologies like text-only browsers, you should try your site without JavaScript. You might be, unpleasantly, surprised.