Rock with a web form, next to a hammer and chisel

Working with Forms

How to process forms, validate them, and submit without reloading the page using JavaScript.

Time to read: over 15 min

Briefly

In addition to standard tools for working with forms, you can use JavaScript to check forms for validity, access values, and send information to the server.

Tricks for working with forms in JavaScript are best demonstrated through an example. In this article, we will collect an application form to participate in the mission to colonize Mars. In this form, we will spice up standard HTML attributes with some JavaScript dynamics.

Markup and Requirements

Our application form for the "Mars Once" mission will consist of six fields. In this form, we collect the following data:

  • Name, to know how to address in the response letter.
  • Email, to know where to send this letter.
  • Age — only young people are needed 🤷‍♂️
  • Specialization — engineers and scientists will be useful for the main work, and psychologists are needed so that the team does not bite each other over a decade-long colonization mission.
  • Whether the person has worked at NASA — this is a big plus.
  • Photo, to use in printed materials.
        
          
          <form id="mars-once" action="/apply/" method="POST">  <label>    Your name:    <input      type="text"      name="name"      id="name"      placeholder="Elon Musk"      required      autofocus    >  </label>  <label>    Email:    <input      type="email"      name="email"      id="email"      placeholder="elon@musk.com"      required    >  </label>  <label>    Age:    <input type="number" name="age" required>  </label>  <label>    Profession:    <select name="specialization" required>      <option value="engineer" selected>Engineer</option>      <option value="scientist">Scientist</option>      <option value="psychologist">Psychologist</option>      <option value="other">Other</option>    </select>  </label>  <label>    I have worked at NASA    <input type="checkbox" name="nasa-experience" value="1">  </label>  <label>    Photo:    <input      type="file"      accept="image/jpeg"      name="photo"      required    >  </label>  <button type="submit">Submit application</button></form>
          <form id="mars-once" action="/apply/" method="POST">
  <label>
    Your name:
    <input
      type="text"
      name="name"
      id="name"
      placeholder="Elon Musk"
      required
      autofocus
    >
  </label>

  <label>
    Email:
    <input
      type="email"
      name="email"
      id="email"
      placeholder="elon@musk.com"
      required
    >
  </label>

  <label>
    Age:
    <input type="number" name="age" required>
  </label>

  <label>
    Profession:
    <select name="specialization" required>
      <option value="engineer" selected>Engineer</option>
      <option value="scientist">Scientist</option>
      <option value="psychologist">Psychologist</option>
      <option value="other">Other</option>
    </select>
  </label>

  <label>
    I have worked at NASA
    <input type="checkbox" name="nasa-experience" value="1">
  </label>

  <label>
    Photo:
    <input
      type="file"
      accept="image/jpeg"
      name="photo"
      required
    >
  </label>

  <button type="submit">Submit application</button>
</form>

        
        
          
        
      
Open demo in the new window

In general, the form is functional: required fields will not skip empty values, the type attribute will ensure that a phone number is not sent instead of an email, and upon clicking the button, a valid form will submit all data.

However, besides all this, we want:

  • for the page not to reload upon submission;
  • to show a loader during the request, show a congratulations message upon successful submission, and indicate the reason for the error in case of failure;
  • to keep the button disabled until the form is valid.

Let's get started.

Submission without Reloading

First, let's set up the form to submit without reloading the page.

Page reload is the default behavior for form submission. To prevent it, we need to "intercept" control at the moment of submission and tell the form what to do instead.

Preventing Data Submission

To "prevent" event triggers, we can use the preventDefault() method on the event object. In our case, the event will be the form submission — submit.

If our event is stored in the variable event, we can call event.preventDefault() to prevent default behavior.

To "connect" the form to our future custom data submission, we will write a function that will "listen" for the submit event and respond to it.

Let's find the form on the page using getElementById and subscribe to the submit event using addEventListener. For now, we won't send the form; we'll just log "Submitting!" to the console and ensure the mechanism works:

        
          
          function handleFormSubmit(event) {  // Ask the form not to submit data on its own  event.preventDefault()  console.log('Submitting!')}const applicantForm = document.getElementById('mars-once')applicantForm.addEventListener('submit', handleFormSubmit)
          function handleFormSubmit(event) {
  // Ask the form not to submit data on its own
  event.preventDefault()
  console.log('Submitting!')
}

const applicantForm = document.getElementById('mars-once')
applicantForm.addEventListener('submit', handleFormSubmit)

        
        
          
        
      

We can simply pass the handleFormSubmit() function as the second argument to addEventListener() since it will automatically pass the event as an argument to handleFormSubmit().

Thus, when the form is submitted, the submit event will trigger our handleFormSubmit() handler.

This handler will receive the submit event as the argument event. We will call event.preventDefault(), and the form will not submit on its own.

Collecting Data from the Form

The next step is to collect everything that needs to be sent.

We don't want to collect each value individually.

  • This can be tedious: if the form has 10 fields, that requires quite a bit of code.
  • It's not scalable: if we want to add a couple more fields, we will have to write code for those fields too.

Instead, we will use the capabilities of the language to extract all fields and controls from the form. Let's write a serializeForm function:

        
          
          function serializeForm(formNode) {  console.log(formNode.elements)}function handleFormSubmit(event) {  event.preventDefault()  serializeForm(applicantForm)}const applicantForm = document.getElementById('mars-once')applicantForm.addEventListener('submit', handleFormSubmit)
          function serializeForm(formNode) {
  console.log(formNode.elements)
}

function handleFormSubmit(event) {
  event.preventDefault()
  serializeForm(applicantForm)
}

const applicantForm = document.getElementById('mars-once')
applicantForm.addEventListener('submit', handleFormSubmit)

        
        
          
        
      

The argument of the serializeForm function is the form element. It is an element — not a selector, but a specific node in the DOM tree.

Forms have an elements property that contains all controls and fields of that form. We will use this property to get all the data from the form.

If we call this function now, passing in our form as an argument, we would see a list of all elements in the console:

HTMLFormControlsCollection
  0 <input type="text" name="name" id="name" placeholder="Elon Musk" autofocus>
  1 <input type="email" name="email" id="email" placeholder="elon@musk.com">
  2 <input type="number" name="age">
  3 <select name="specialization">
  4 <input type="checkbox" name="nasa-experience" value="1">
  5 <input type="file" accept="image/jpeg" name="photo">
  6 <button type="submit">Submit application</button>

Note that the type of this collection is HTMLFormControlsCollection. It is not an array, and to iterate through this list of elements, we need to convert it into an array using Array.from().

We will collect the name and value of each field. First, let's log each element's name and value to the console:

        
          
          function serializeForm(formNode) {  const { elements } = formNode  Array.from(elements)    .forEach((element) => {      const { name, value } = element      console.log({ name, value })    })}
          function serializeForm(formNode) {
  const { elements } = formNode

  Array.from(elements)
    .forEach((element) => {
      const { name, value } = element
      console.log({ name, value })
    })
}

        
        
          
        
      

We get a list of elements, convert it into an array, and iterate through each element. For each element, we obtain the name and value fields and log them to the console.

In the console after running, we will get output for each of the fields:

1 {name: 'name', value: 'Alex'}
2 {name: 'email', value: 'some@mail.com'}
3 {name: 'age', value: '24'}
4 {name: 'specialization', value: 'engineer'}
5 {name: 'nasa-experience', value: '1'}
6 {name: 'photo', value: 'C:\\fakepath\\image.jpg'}
7 {name: '', value: ''}

Note that the last line lacks both a name and a value. This is because the last element we checked was a button.

To avoid interference from elements without names, we will filter our set. We will use the filter method to discard elements with empty names. We will also replace the forEach method with map — it will collect an array that stores an object with the name and value of each filtered element.

        
          
          function serializeForm(formNode) {  const { elements } = formNode  const data = Array.from(elements)    .filter((item) => !!item.name)    .map((element) => {      const { name, value } = element      return { name, value }    })  console.log(data)}
          function serializeForm(formNode) {
  const { elements } = formNode
  const data = Array.from(elements)
    .filter((item) => !!item.name)
    .map((element) => {
      const { name, value } = element

      return { name, value }
    })

  console.log(data)
}

        
        
          
        
      

In the end, the console will show an array of objects with name and value:

        
          
          [  {name: 'name', value: 'Alex'},  {name: 'email', value: 'some@mail.com'},  {name: 'age', value: '24'},  {name: 'specialization', value: 'engineer'},  {name: 'nasa-experience', value: '1'},  {name: 'photo', value: 'C:\\fakepath\\image.jpg'}]
          [
  {name: 'name', value: 'Alex'},
  {name: 'email', value: 'some@mail.com'},
  {name: 'age', value: '24'},
  {name: 'specialization', value: 'engineer'},
  {name: 'nasa-experience', value: '1'},
  {name: 'photo', value: 'C:\\fakepath\\image.jpg'}
]

        
        
          
        
      

Checkbox Values

Now it can be noted that nasa-experience has the value "1". This is incorrect:

  • we did not check the checkbox, yet the value is "1";
  • overall, we would like this field's value to be boolean.

For this, we can use a special checked property that checkboxes have.

        
          
          const isOn = someCheckboxInput.checked
          const isOn = someCheckboxInput.checked

        
        
          
        
      

The value of this field is indeed boolean, and we can use this in our serializeForm function.

However, we want to use this property only on the checkbox, not on the other fields. This can also be done. We can read the type of the element and, if it is "checkbox", take the checked field as the value:

        
          
          function serializeForm(formNode) {  const { elements } = formNode  const data = Array.from(elements)    .filter((item) => !!item.name)    .map((element) => {      const { name, type } = element      const value = type === 'checkbox' ? element.checked : element.value      return { name, value }    })  console.log(data)}
          function serializeForm(formNode) {
  const { elements } = formNode

  const data = Array.from(elements)
    .filter((item) => !!item.name)
    .map((element) => {
      const { name, type } = element
      const value = type === 'checkbox' ? element.checked : element.value

      return { name, value }
    })

  console.log(data)
}

        
        
          
        
      

Now the value of the nasa-experience field will be true if the checkbox is checked and false if it is missed. We will now see such output:

        
          
          [  {name: 'name', value: 'Alex'},  {name: 'email', value: 'some@mail.com'},  {name: 'age', value: '24'},  {name: 'specialization', value: 'engineer'},  {name: 'nasa-experience', value: false},  {name: 'photo', value: 'C:\\fakepath\\image.jpg'}]
          [
  {name: 'name', value: 'Alex'},
  {name: 'email', value: 'some@mail.com'},
  {name: 'age', value: '24'},
  {name: 'specialization', value: 'engineer'},
  {name: 'nasa-experience', value: false},
  {name: 'photo', value: 'C:\\fakepath\\image.jpg'}
]

        
        
          
        
      

Data Format

In general, the current data format as an array of objects may suit us, but we want to use something better — FormData.

FormData is a special data type that can be used to send form data to the server.

We will use it to save the data from the form. We will create an instance using new FormData(), abandon the array of values, and will add field names and their values to FormData by calling the append function:

        
          
          function serializeForm(formNode) {  const { elements } = formNode  const data = new FormData()  Array.from(elements)    .filter((item) => !!item.name)    .forEach((element) => {      const { name, type } = element      const value = type === 'checkbox'        ? element.checked        : element.value      data.append(name, value)    })  return data}
          function serializeForm(formNode) {
  const { elements } = formNode

  const data = new FormData()

  Array.from(elements)
    .filter((item) => !!item.name)
    .forEach((element) => {
      const { name, type } = element
      const value = type === 'checkbox'
        ? element.checked
        : element.value

      data.append(name, value)
    })

  return data
}

        
        
          
        
      

However, since the FormData type is specifically created for working with forms, we can make it much simpler 🙂

        
          
          function serializeForm(formNode) {  return new FormData(formNode)}
          function serializeForm(formNode) {
  return new FormData(formNode)
}

        
        
          
        
      

It is worth noting that nasa-experience will only appear in the final data if the checkbox is checked. If it is not checked, then it will not appear in the final data.

To check what data is contained in a FormData variable, you can use the .entries() method, which will output a list of data like shown above.

        
          
          console.log(Array.from(data.entries()))
          console.log(Array.from(data.entries()))

        
        
          
        
      

Sending to the Server

Now we need to send the data from the form to the server. Let's assume our backend provides an API endpoint for saving data. Let's try to send them.

The function will be asynchronous because it works with network requests. It takes FormData as an argument and sends the request using fetch:

        
          
          async function sendData(data) {  return await fetch('/api/apply/', {    method: 'POST',    body: data,  })}
          async function sendData(data) {
  return await fetch('/api/apply/', {
    method: 'POST',
    body: data,
  })
}

        
        
          
        
      

The function will return the result of the request to the server, which we can check for errors.

Now we will use this function in the submit event handler. We will serialize the form and pass it to the sending function. Instead of directly accessing the form, we will read it from the event object. The form in the submit event object will be stored in the target property:

        
          
          async function handleFormSubmit(event) {  event.preventDefault()  const data = serializeForm(event.target)  const response = await sendData(data)}
          async function handleFormSubmit(event) {
  event.preventDefault()

  const data = serializeForm(event.target)
  const response = await sendData(data)
}

        
        
          
        
      

Note that the handleFromSubmit() function has become asynchronous since it calls another asynchronous function and waits for its result. Inside, response will contain a status field, which we can use to determine whether the request was successful and display a corresponding message to the user.

Handling Loading and Displaying Result Messages

Now let's slightly improve the UX of our form. Right now it just submits data and doesn't inform users about anything. That's not cool, because the sender will be confused about whether they successfully signed up for "Mars Once" or not.

We want:

  • to show a loader when submitting the form while the request is in progress;
  • to show a message that the form has been submitted successfully and hide the form upon successful submission;
  • to indicate to the user where the error occurred in case of a mistake.

Let's start with the loader.

Show Loader During Submission

Instead of a loader, we will simply display the string "Sending..."

We will add it after the button and hide it:

        
          
          <style>  .hidden {    display:none;  }</style><form id="mars-once" action="/apply/" method="POST">  <!-- The rest of the form code -->  <button type="submit">Submit application</button>  <div id="loader" class="hidden">Sending...</div></form>
          <style>
  .hidden {
    display:none;
  }
</style>
<form id="mars-once" action="/apply/" method="POST">
  <!-- The rest of the form code -->

  <button type="submit">Submit application</button>
  <div id="loader" class="hidden">Sending...</div>
</form>

        
        
          
        
      

We hide it because we want to show it only during the request. For this, we will write a function to manage its state — make the loader visible if it is not currently visible and hide it if it is. Since technically this involves adding and removing the hidden class, we can use the toggle function from the classList API:

        
          
          function toggleLoader() {  const loader = document.getElementById('loader')  loader.classList.toggle('hidden')}
          function toggleLoader() {
  const loader = document.getElementById('loader')
  loader.classList.toggle('hidden')
}

        
        
          
        
      

We will call this function before sending the request to display the loader and after the request to hide it. The loader will be visible until the request is complete:

        
          
          async function handleFormSubmit(event) {  event.preventDefault()  const data = serializeForm(event.target)  toggleLoader()  const response = await sendData(data)  toggleLoader()}
          async function handleFormSubmit(event) {
  event.preventDefault()
  const data = serializeForm(event.target)

  toggleLoader()

  const response = await sendData(data)

  toggleLoader()
}

        
        
          
        
      

Handling Successful Submissions

Now let's check the server response. Let's assume we want to show an alert() with a message about successful submission and hiding the form upon successful submission:

        
          
          function onSuccess(formNode) {  alert('Your application has been submitted!')  formNode.classList.toggle('hidden')}
          function onSuccess(formNode) {
  alert('Your application has been submitted!')
  formNode.classList.toggle('hidden')
}

        
        
          
        
      

We should call onSuccess only if the form was submitted successfully. For this, we will add a check on the server response status — it should be 200 for success (response statuses are discussed in the HTTP Protocol article):

        
          
          // We will call it like thisasync function handleFormSubmit(event) {  event.preventDefault()  const data = serializeForm(event.target)  toggleLoader()  const { status } = await sendData(data)  toggleLoader()  if (status === 200) {    onSuccess(event.target)  }}
          // We will call it like this
async function handleFormSubmit(event) {
  event.preventDefault()
  const data = serializeForm(event.target)

  toggleLoader()
  const { status } = await sendData(data)
  toggleLoader()

  if (status === 200) {
    onSuccess(event.target)
  }
}

        
        
          
        
      
Screenshot of successful submission message

Upon successful submission, this message will appear, and the form will disappear.

Handling Errors

If something goes wrong, we want to tell users about it. Let's write a function that will call alert() with the message the server sends in case of an error:

        
          
          function onError(error) {  alert(error.message)}
          function onError(error) {
  alert(error.message)
}

        
        
          
        
      

We could call alert immediately on the spot, but it’s better to separate error handling into its own function. This way, if we want to add any actions for handling errors, it will be easier to navigate the code.

Along with the status, we will get information about the error from the error field. If the request was successful, error will be empty, but in case of an error, there will be a message there:

        
          
          async function handleFormSubmit(event) {  event.preventDefault()  const data = serializeForm(event.target)  toggleLoader()  const { status, error } = await sendData(data)  toggleLoader()  if (status === 200) {    onSuccess(event.target)  } else {    onError(error)  }}
          async function handleFormSubmit(event) {
  event.preventDefault()
  const data = serializeForm(event.target)

  toggleLoader()

  const { status, error } = await sendData(data)
  toggleLoader()

  if (status === 200) {
    onSuccess(event.target)
  } else {
    onError(error)
  }
}

        
        
          
        
      
Screenshot of error message

If something goes wrong, we will see the reason. The form will stay in place.

Disabling Submit Button on Invalid Form

Right now, the submit button can be clicked at any moment, even if the form is invalid. And although the user cannot submit the form due to HTML validation, it would be nice to warn them that the button should not be pressed just yet.

Let's disable it until all required fields are filled.

We will write a function that will check the form's validity and disable the button if needed. The argument will be the input event from the keyboard on the input fields.

Since the input event will occur on the fields and not on the form itself, the value of event.target will be the field. To get the form, we will use the form property, which contains a reference to the parent form.

We will check the form's validity using the checkValidity() method of the form. It triggers standard checks. We will use the result to set the button's disabled property to true if it needs to be disabled, and false if the button should be available.

        
          
          function checkValidity(event) {  const formNode = event.target.form  const isValid = formNode.checkValidity()  formNode.querySelector('button').disabled = !isValid}applicantForm.addEventListener('input', checkValidity)
          function checkValidity(event) {
  const formNode = event.target.form
  const isValid = formNode.checkValidity()

  formNode.querySelector('button').disabled = !isValid
}

applicantForm.addEventListener('input', checkValidity)

        
        
          
        
      
Screenshot of disabled submit button

Now, as long as the form is not filled out, the button will be disabled.

What We Achieved

We created a form that the user can submit without reloading the page, displays success or error messages, and disables the button until values are entered.

Final view of the form

For all of this, we used methods of HTML elements and form elements that are provided to us by the browser and the web platform.

Of course, working with forms does not end here. We can also do validation for each field individually, upload images with the possibility of editing them, add various combo boxes, and non-standard elements.

But for the first form we worked with in JavaScript, this is enough 🙂