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>
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 prevent
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
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 get
and subscribe to the submit
event using add
. 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 handle
function as the second argument to add
since it will automatically pass the event as an argument to handle
.
Thus, when the form is submitted, the submit
event will trigger our handle
handler.
This handler will receive the submit event as the argument event
. We will call event
, 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 serialize
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 serialize
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 HTML
. It is not an array, and to iterate through this list of elements, we need to convert it into an array using Array
.
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 for
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
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 serialize
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
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 — Form
.
Form
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
, abandon the array of values, and will add field names and their values to Form
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 Form
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
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 Form
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 Form
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 handle
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 class
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 on
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) } }

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) } }

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
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 check
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)

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.

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 🙂