{% hint style="info" %} Follow along with code examples here!
Clone down the repo, cd into it and run npm i to install dependencies.
{% endhint %}
Table of Contents:
- Key Concepts
- Forms Review: What We Learned in Module 3
- Handling Forms With JavaScript
- Sending Form Data to APIs
- Additional Reading
- Default form behavior - collect input data, refresh the current page, and send the data to the URL specified in the
actionattribute using the HTTPGETmethod (orPOSTif specified) - FormData API — the modern approach to extracting all form values as an object
- Form validation — checking user input before processing the form data
- Form reset — clearing all form inputs after successful submission
Key Syntax
event.preventDefault()— prevents default form submission behaviorform.elements.fieldName.value— access input values by name using form.elementsform.elements.checkboxName.checked— access checkbox checked state (returnstrue/false)new FormData(form)— creates a FormData object from a form elementObject.fromEntries(formData)— converts FormData to a plain JavaScript objectform.reset()— clears all form inputs to their default values
In Module 3, we learned how to build forms using HTML and CSS:
- How to structure forms with
<form>,<label>,<input>, and<button>elements - Different input types:
text,number,email,date,checkbox,radio,<textarea>,<select> - Connecting labels to inputs using
forandidattributes for accessibility - The
nameattribute on inputs (which we'll use extensively in this lesson!) - HTML validation attributes:
required,min,max,minlength,maxlength,pattern
We also learned how to capture the form data and send it to Formspree using the action and method attributes:
<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST">
<div>
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<button type="submit">Sign Up</button>
</form>Recall that after submitting the form you would be redirected to the Formspree.
Q: Take a look at the input elements. What is the difference between the id and name attributes?
- The
idattribute labels the field so that it can be connected to the<label>element - The
nameattribute gives the form value a name when it is submitted. Remember this!
Q: What does the method="POST" attribute mean?
A POST request means that we are sending data to a server as opposed to requesting data from it.
Using services like Formspree is great for simple contact forms, but it has limitations:
- ❌ You lose control — the data is sent away and you can't do anything with it in your app
- ❌ The page reloads/redirects — this breaks the user experience in modern single-page applications
- ❌ No custom validation — you can only use basic HTML validation
- ❌ No dynamic behavior — you can't update the page based on the submitted data
Modern web applications handle forms with JavaScript instead, which allows us to:
- ✅ Keep users on the same page — no reload or redirect
- ✅ Do whatever we want with the data — display it, store it, send it to an API
- ✅ Provide instant feedback — show success messages, validation errors, loading states
- ✅ Create dynamic experiences — add items to a todo list, submit reviews, create posts
Let's start with a simple form with a single field and a button. Take a look at 0-contact-form/index.html
{% hint style="info" %} Remember, we are using the Vite development server. To get the app running on the development server:
cdinto the directorynpm ito install Vite dependencies (we did this earlier at the repo root)npm run devto start the serverctrl+cto stop the server {% endhint %}
<form id="contact-form" action="https://formspree.io/f/xojnjqkp" method="POST">
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
<!-- Display the form results here -->
<div class="output">
<h3 id="contact-output-status"></h3>
<p>Message: <span id="contact-output-message"></span></p>
</div>Try filling out the form. Confirm the behavior—the data is set to Formspree and you are redirected to another page.
Instead, let's prevent this default behavior and handle the form submission ourselves. We'll display a brief status message like "Message Received!" in the empty h3 element and the message in the empty span element:
To do this, we'll need to:
- Prevent the default page reload/redirect behavior
- Extract the form data from the inputs using the
nameattribute - Use the data (display it, send it to an API, etc.)
- Reset the form
// Step 1: Grab the form and the output elements
const contactForm = document.querySelector('#contact-form');
const contactOutputStatus = document.querySelector("#contact-output-status")
const contactOutputMessage = document.querySelector("#contact-output-message")
// Step 2: Add a "submit" event listener to the form (don't forget event!)
contactForm.addEventListener('submit', (event) => {
// Step 3: event.preventDefault()
event.preventDefault();
// Step 4: Get the form data
const message = contactForm.elements.message.value;
// Step 5: Use the form data (display it)
contactOutputStatus.textContent = "Message Received!";
contactOutputMessage.textContent = message;
// Step 6: Reset the form
contactForm.reset();
});Let's look closer at the key parts of the JavaScript:
contactForm.addEventListener('submit', (event) => {})
- The
"submit"event is fired when the user presses the submit button. - The event handler should use the
eventparameter for preventing the default behavior.
event.preventDefault()
event.preventDefault()stops the browser from doing its default action (reload/redirect)- It must be called at the start of the handler. Otherwise the page will reload and your JavaScript won't run!
- Try removing it to see for yourself!
contactForm.elements.message.value
contactForm.elementsis an object containing all inputs in the form.- Inside of it, you can access inputs by their
nameattribute (e.g.,form.elements.message). - Then, use
.valueto get the current value of the input (e.g.form.elements.message.value)
Status Message
- When handling form submissions, it is a good practice to let your user know if the form submission worked!
- In this example we always display a success message but you can also show error messages if things like API calls fail
form.reset()
- Clears all inputs back to their default values
- Useful after successful submission
Let's add name and email inputs to this contact form and display them alongside the message.
HTML
- Add two form inputs to the html, one for the name and one for the email
- make sure to give them
nameattributes!
- make sure to give them
- add an output element in the
divto display the message (use a<p>with a<span>inside).- The format should be
"From: Ada Lovelace (ada@mail.com)".
- The format should be
JavaScript
- Grab the output element.
- In the event handler, extract the values for the
nameand theemail. - Display the formatted message in the output element.
It should look like this:
Solution
HTML:
<form id="contact-form" action="https://formspree.io/f/xojnjqkp" method="POST">
<div>
<label for="name">Name</label>
<input id="name" name="name" required></input>
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" required></input>
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>JavaScript:
// Step 1: Grab the form and the output elements
const contactForm = document.querySelector('#contact-form');
const contactOutputStatus = document.querySelector("#contact-output-status")
const contactOutputMessage = document.querySelector("#contact-output-message")
const contactOutputFrom = document.querySelector("#contact-output-from")
// Step 2: Add a "submit" event listener to the form(don't forget event!)
contactForm.addEventListener('submit', (event) => {
// Step 3: event.preventDefault()
event.preventDefault();
// Step 4: Get the form data
const message = contactForm.elements.message.value;
const name = contactForm.elements.name.value;
const email = contactForm.elements.email.value;
// Step 5: Use the form data (display it)
contactOutputMessage.textContent = message;
contactOutputFrom.textContent = `${name} (${email})`;
contactOutputStatus.textContent = "Message Received!";
// Step 6: Reset the form
contactForm.reset();
});You'll notice that we aren't sending the data to Formspree anymore. Now, we can use JavaScript to send the data ourselves using fetch with a POST request — keeping the user on the same page:
contactForm.addEventListener('submit', (event) => {
event.preventDefault();
const message = contactForm.elements.message.value;
const name = contactForm.elements.name.value;
const email = contactForm.elements.email.value;
// Gather the data in an object before sending
const formValues = { message, name, email };
// Set up the POST request config
const config = {
method: 'POST',
body: JSON.stringify(formValues),
headers: {
'content-type': 'application/json', // <-- the data format we're sending
'accept': 'application/json' // <-- the data format we can receive
}
};
// Send the fetch
fetch("https://formspree.io/f/FORMSPREE_URL", config)
// Update the UI
contactOutputMessage.textContent = message;
contactOutputFrom.textContent = `${name} (${email})`;
contactOutputStatus.textContent = "Message Received!";
// Step 6: Reset the form
contactForm.reset();
});Let's break down the key parts:
The fetch configuration object:
method: 'POST'— tells the server we're creating/sending new databody: JSON.stringify(formValues)— converts our JavaScript object to a JSON stringheaders: { 'content-type': 'application/json', 'accept': 'application/json' }— tells the server we're sending JSON data and are accepting JSON in response
Why use JSON.stringify()?
The body of a fetch request must be a string, not a JavaScript object. JSON.stringify() converts our object into a JSON-formatted string that can be sent over the network:
const formValues = { name: 'Ada', email: 'ada@mail.com', message: 'Hello!' };
console.log(JSON.stringify(formValues));
// '{"name":"Ada","email":"ada@mail.com","message":"Hello!"}'Even though we're sending data (not requesting it), the API still sends back a response. We check response.ok to see if it succeeded, then update the UI accordingly.
fetch("https://formspree.io/f/FORMSPREE_URL", config)
.then((response) => {
if (!response.ok) {
throw Error(`Failed to submit. ${response.status} ${response.statusText}`);
}
return response.json();
})
.then((data) => {
console.log('Success:', data);
// Move the UI updates here
contactOutputMessage.textContent = message;
contactOutputFrom.textContent = `${name} (${email})`;
contactOutputStatus.textContent = "Message Received!";
})
.catch((error) => {
console.error('Error:', error);
// Show the user an error message
contactOutputStatus.textContent = 'Failed to send message. Please try again later.';
});Here is the same logic using the async/await and try/catch syntax
// remember to make the event handler async!
contactForm.addEventListener('submit', async (event) => {
// preventing default, extracting data, configuration...
try {
const response = await fetch("https://formspree.io/f/FORMSPREE_URL", config);
if (!response.ok) {
throw Error(`Failed to submit. ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('Success:', data);
contactOutputMessage.textContent = message;
contactOutputFrom.textContent = `${name} (${email})`;
contactOutputStatus.textContent = "Message Received!";
}
catch (error) {
console.error('Error:', error);
contactOutputStatus.textContent = 'Failed to send message. Please try again later.';
}
contactForm.reset();
});Let's add a checkbox to our contact form. Suppose we want to give users the option to send the message anonymously.
HTML:
<form id="contact-form">
<!-- Other form elements... -->
<div class="checkbox-field">
<label for="anonymous">Send anonymously</label>
<input type="checkbox" id="anonymous" name="anonymous">
</div>
<button type="submit">Send Message</button>
</form>Checkboxes work differently from text inputs. Every form input has a .value property, but for checkboxes:
- The
.valueis always the string"on"whether or not the box is actually checked. - The
.checkedproperty returnstrueif the box is checked orfalseif not.
So, here is the full JavaScript that uses the checkbox to display the user as "Anonymous" if they so choose:
const contactForm = document.querySelector('#contact-form');
contactForm.addEventListener('submit', (event) => {
event.preventDefault();
// For text inputs, use .value
const name = contactForm.elements.name.value; // "Ada Lovelace"
const email = contactForm.elements.email.value; // "ada@mail.com"
const message = contactForm.elements.message.value; // "Hello!"
// For checkboxes, use .checked (not .value!)
const isAnonymous = contactForm.elements.anonymous.checked; // true or false
// Now we can use the boolean to conditionally display the sender
const sender = isAnonymous ? "Anonymous" : `${name} (${email})`;
console.log(`From: ${sender}`);
console.log(`Message: ${message}`);
});Tip: You can reduce repetition of formElement.elements by destructuring:
contactForm.addEventListener('submit', (event) => {
event.preventDefault();
// Destructure the elements to get each form input
const { name, email, message, anonymous } = contactForm.elements;
// remember to use .checked
const sender = anonymous.checked ? "Anonymous" : `${name.value} (${email.value})`;
contactOutputStatus.textContent = "Message Received!";
contactOutputFrom.textContent = sender;
contactOutputMessage.textContent = message.value;
contactForm.reset();
});The FormData API is a more modern approach to extracting data from a form. It automatically extracts ALL form values into a single FormData object. Since it is not an ordinary object, we need to convert it first using Object.fromEntries():
const contactForm = document.querySelector('#contact-form');
contactForm.addEventListener('submit', (event) => {
event.preventDefault();
// 1. Create a FormData object from the form
const formData = new FormData(contactForm);
// 2. Convert FormData to a plain JavaScript object
const formValues = Object.fromEntries(formData);
// Form inputs are stored in name:value pairs
console.log(formValues);
// { name: 'Ada', email: 'ada@mail.com', message: 'Hello!', anonymous: 'on' }
// So we can extract the values directly
const { anonymous, name, email, message } = formValues;
const sender = anonymous ? "Anonymous" : `${name} (${email})`;
contactOutputStatus.textContent = "Message Received!";
contactOutputFrom.textContent = sender;
contactOutputMessage.textContent = message;
contactForm.reset();
});Let's break this down:
new FormData(form)
- Creates a
FormDataobject containing all the form's input values - Automatically finds all inputs with a
nameattribute - The
FormDataobject is iterable but not directly usable as a normal object. Instead, it has "entries".
Object.fromEntries(formData)
- Converts the FormData object into a plain JavaScript object
- Each input's
namebecomes a property - Each input's
valuebecomes the property value
Often, the FormData and Object.fromEntries calls are combined into one.
const contactForm = document.querySelector('#contact-form');
contactForm.addEventListener('submit', (event) => {
event.preventDefault();
const formValues = Object.fromEntries(new FormData(contactForm));
const { anonymous, name, email, message } = formValues;
const sender = anonymous ? "Anonymous" : `${name} (${email})`;
contactOutputStatus.textContent = "Message Received!";
contactOutputFrom.textContent = sender;
contactOutputMessage.textContent = message;
contactForm.reset();
});Pros:
- ✅ Concise — just two lines to get all form data
- ✅ Automatic — grabs all inputs without listing them
- ✅ Less code to maintain when adding/removing fields
Cons:
- ❌ Less explicit — harder to see which fields exist
- ❌ Checkbox gotcha (see below)
Remember how we said checkbox .value is always "on"? That's exactly what FormData gives you:
const formValues = Object.fromEntries(new FormData(contactForm));
console.log(formValues);
/*
{
name: 'Ada',
email: 'ada@mail.com',
message: 'Hello!',
anonymous: 'on', // <-- Checked checkbox gives "on", not true!
}
*/And if the checkbox is unchecked, it won't be included at all:
// If anonymous checkbox is NOT checked:
console.log(formValues);
/*
{
name: 'Ada',
email: 'ada@mail.com',
message: 'Hello!',
// anonymous is undefined — not even in the object!
}
*/For basic conditionals and ternary operations, this works fine because of JavaScript's truthiness:
const formValues = Object.fromEntries(new FormData(contactForm));
const { anonymous, name, email, message } = formValues;
// This works because "on" is truthy, undefined is falsy
const sender = anonymous ? "Anonymous" : `${name} (${email})`;However, the most common use case for the FormData is when packaging the entire object to be sent to an API (which we'll see shortly).
Most APIs will prefer receiving a booleans rather than a value that could either be "on"/undefined. So, we often will reassign a checkbox value after extracting it with FormData:
const formValues = Object.fromEntries(new FormData(contactForm));
const { anonymous, name, email, message } = formValues;
// Convert checkbox to boolean for cleaner data
formValues.anonymous = Boolean(anonymous);
console.log(formValues);
// { name: 'Ada', email: 'ada@mail.com', message: 'Hello!', anonymous: true }If you have multiple checkboxes, we need to convert each one:
const formValues = Object.fromEntries(new FormData(contactForm));
formValues.anonymous = Boolean(formValues.anonymous);
formValues.subscribe = Boolean(formValues.subscribe);
formValues.acceptTerms = Boolean(formValues.acceptTerms);Test your skills by building your own form with Formspree from scratch! We've given you some code to start with in 1-form-challenge/ but it will be up to you to:
- Create a new Formspree form
- Go to formspree.io and create a free account
- Create a new form and copy the endpoint URL (looks like
https://formspree.io/f/xyzabc123)
- Create a form with inputs. It is up to you what data you want to collect but your form should have:
- At least one text input field
- At least one checkbox field
- A submit button
- A
nameattribute for every input - A
labelfor every input
- Use the
FormDataapproach to extract the form values. Remember to update the checkbox inputs! - Submit the form data to the Formspree URL using a POST request and
fetch() - Render a success or an error message
- Don't worry about styling. Just aim for functionality!
Use the example in 0-contact-form-complete as a guide for what this looks like when completed!
Besides the submit event, there are several other useful form events:
input event — fires every time an input's value changes (as you type)
const nameInput = document.querySelector('#name');
nameInput.addEventListener('input', (event) => {
console.log('Current value:', event.target.value);
// Great for: live character counters, search-as-you-type, instant validation
});change event — fires when an input's value changes AND the input loses focus
const selectMenu = document.querySelector('#country');
selectMenu.addEventListener('change', (event) => {
console.log('Selected:', event.target.value);
// Great for: dropdowns, radio buttons, checkboxes
});focus and blur events — fires when an input gains or loses focus
const emailInput = document.querySelector('#email');
emailInput.addEventListener('focus', () => {
console.log('Email input focused');
// Great for: showing help text, highlighting the field
});
emailInput.addEventListener('blur', () => {
console.log('Email input lost focus');
// Great for: validating after user finishes typing
});Non-submit buttons — buttons with type="button" don't submit the form
<button type="button" id="normal-button">Capitalize Name</button>const normalButton = document.querySelector('#normal-button');
normalButton.addEventListener('click', () => {
const form = document.querySelector('form');
const nameInput = form.elements.name;
nameInput.value = nameInput.value.toUpperCase();
});This is useful for buttons that manipulate form data without submitting!
HTML validation attributes (required, min, max, etc.) are great, but sometimes we need custom validation logic. JavaScript gives us complete control!
Let's build a registration form with custom validation:
Check out this example in 2-registration-form:
HTML: In the HTML, pay attention to the <span class="error" id="input-name-error"></span> elements that have been added for each input.
<form id="registration-form">
<div>
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<span class="error" id="username-error"></span>
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<span class="error" id="email-error"></span>
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<span class="error" id="password-error"></span>
</div>
<div>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" name="confirmPassword" required>
<span class="error" id="confirm-error"></span>
</div>
<button type="submit">Register</button>
</form>
<div id="success-message">
<h3 id="success-heading"></h3>
<p id="success-welcome"></p>
</div>CSS: Here, we've created a class to make errors stand out.
.error {
color: red;
font-size: 0.875rem;
display: block;
/* Reserve space even when empty */
min-height: 1.25rem;
}JavaScript:
In the JavaScript, pay attention to how the helper functions work within the form submission event handler.
const registrationForm = document.querySelector('#registration-form');
const successHeading = document.querySelector('#success-heading');
const successWelcome = document.querySelector('#success-welcome');
// Helper Function: Form validation
const validateForm = (formData) => {
const errors = {};
// Username must be at least 3 characters
if (formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
// Email must contain @ symbol (basic check)
if (!formData.email.includes('@')) {
errors.email = 'Please enter a valid email address';
}
// Password must be at least 8 characters and contain a number
if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
} else if (!/\d/.test(formData.password)) {
errors.password = 'Password must contain at least one number';
}
// Passwords must match
if (formData.password !== formData.confirmPassword) {
errors.confirm = 'Passwords do not match';
}
return errors;
};
// Helper Function: Display error messages
const displayErrors = (errors) => {
// Clear all previous errors
document.querySelectorAll('.error').forEach(span => span.textContent = '');
// Display new errors
Object.keys(errors).forEach(field => {
const errorSpan = document.querySelector(`#${field}-error`);
if (errorSpan) {
errorSpan.textContent = errors[field];
}
});
};
registrationForm.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.target;
const formData = Object.fromEntries(new FormData(form));
// Validate the form data to get any errors
const errors = validateForm(formData);
// If there are errors, display them and stop
if (Object.keys(errors).length > 0) {
displayErrors(errors);
return; // Don't submit if there are errors!
}
// If there are no errors, clear out any previous errors
displayErrors({});
// Update the success message
successHeading.textContent = 'Registration Successful!';
successWelcome.textContent = `Welcome, ${formData.username}!`;
form.reset();
});This example demonstrates:
- Custom validation logic (username length, password requirements, matching passwords)
- Displaying error messages next to the relevant fields
- Preventing submission if validation fails
- Clearing errors when validation passes
Enhance the registration form above by adding these validation rules:
- Username must:
- Be at least 3 characters
- Contain only letters, numbers, and underscores
- HINT: Use regex
/^[a-zA-Z0-9_]+$/
- Password must:
- Be at least 8 characters
- Contain at least one uppercase letter
- Contain at least one lowercase letter
- Contain at least one number
- Add visual feedback:
- Input borders turn red when there's an error
- Input borders turn green when valid
- Add CSS classes
.errorand.validto inputs
Solution
const registrationForm = document.querySelector('#registration-form');
const successMessage = document.querySelector('#success-message');
const validateForm = (formData) => {
const errors = {};
// Username validation
if (formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
errors.username = 'Username can only contain letters, numbers, and underscores';
}
// Email validation
if (!formData.email.includes('@')) {
errors.email = 'Please enter a valid email address';
}
// Password validation
if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
} else if (!/[A-Z]/.test(formData.password)) {
errors.password = 'Password must contain at least one uppercase letter';
} else if (!/[a-z]/.test(formData.password)) {
errors.password = 'Password must contain at least one lowercase letter';
} else if (!/\d/.test(formData.password)) {
errors.password = 'Password must contain at least one number';
}
// Confirm password
if (formData.password !== formData.confirmPassword) {
errors.confirm = 'Passwords do not match';
}
return errors;
};
const displayErrors = (errors) => {
// Clear all previous error messages and classes
document.querySelectorAll('.error').forEach(span => span.textContent = '');
document.querySelectorAll('input').forEach(input => {
input.classList.remove('error', 'valid');
});
// Display new errors and add error class to inputs
Object.keys(errors).forEach(field => {
const errorSpan = document.querySelector(`#${field}-error`);
const input = document.querySelector(`[name="${field}"]`) ||
document.querySelector('#confirm-password');
if (errorSpan) {
errorSpan.textContent = errors[field];
}
if (input) {
input.classList.add('error');
}
});
// Add valid class to inputs without errors
const formInputs = ['username', 'email', 'password', 'confirmPassword'];
formInputs.forEach(field => {
if (!errors[field]) {
const input = document.querySelector(`[name="${field}"]`) ||
document.querySelector('#confirm-password');
if (input && input.value) {
input.classList.add('valid');
}
}
});
};
const handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
const formData = Object.fromEntries(new FormData(form));
const errors = validateForm(formData);
if (Object.keys(errors).length > 0) {
displayErrors(errors);
return;
}
displayErrors({});
// Clear previous success message
successMessage.textContent = '';
const successHeading = document.createElement('h3');
const welcomeText = document.createElement('p');
successHeading.textContent = 'Registration Successful!';
welcomeText.textContent = `Welcome, ${formData.username}!`;
successMessage.append(successHeading, welcomeText);
form.reset();
};
registrationForm.addEventListener('submit', handleSubmit);Additional CSS:
input.error {
border: 2px solid red;
}
input.valid {
border: 2px solid green;
}
.error {
color: red;
font-size: 0.875rem;
display: block;
min-height: 1.25rem;
}
