Forms from JavaScript
Reading values with FormData, the submit event, validation events, and the dialog element from JS.
The browser's form machinery already does a lot for you: it collects values when you submit, validates required and type="email", focuses the first invalid field, fires submit, sends the request. JavaScript on top of forms is mostly about intercepting those moments, not replacing them.
This lesson covers the four interception points that show up over and over: the submit event, reading the values into a useful shape, listening as the user types, and reacting when a field becomes invalid. Plus a fast tour of the modern <dialog> element and how to drive it from code.
The submit event
Every <form> fires a submit event when the user submits it — by clicking a <button type="submit">, by pressing Enter inside a single-line input, or by your code calling form.requestSubmit(). That event is your one entry point.
const form = document.querySelector('#signup');
form.addEventListener('submit', (event) => {
event.preventDefault(); // do not navigate
// ... read values, send fetch, update page
});The first line in almost every JS form handler is preventDefault. Without it, the browser navigates to the form's action URL the moment your handler returns, and any fetch you started inside is canceled mid-flight. With it, the browser stops, and you take over.
Listen on the <form>, not on a submit <button>. The button click does not capture Enter-to-submit and is a fragile place to hang form logic. The submit event on the form fires for every kind of submission.
FormData — read every input
To read every named input from a form, the right tool is FormData. You hand it the form, and it gives you an object that knows how to iterate over the values.
form.addEventListener('submit', (event) => {
event.preventDefault();
const data = new FormData(form);
// Read one value:
const email = data.get('email');
// Convert the whole form into a plain object:
const all = Object.fromEntries(data);
// { email: 'maya@example.com', plan: 'pro', ... }
fetch('/signup', { method: 'POST', body: data });
});Three useful things about FormData:
- It picks up every input with a
nameattribute. Inputs withoutnameare ignored — they are decorative as far as forms are concerned. - It works directly as a
fetchbody. Pass it asbody: dataand the browser sets the rightContent-Type(including the boundary needed for file uploads) automatically. - It handles multiple values for the same name (checkbox groups, multi-selects). Use
data.getAll('topics')to get all of them as an array.
You will sometimes see code build a JSON object by hand from form.elements. FormData is shorter and harder to break.
<input type="email" id="email"> with no name attribute. The user fills it in, the form submits, and your handler does new FormData(form).get('email'). What do you get?input vs change
Two events fire as the user edits an input. They look similar; they are not.
inputfires on every keystroke, every paste, every increment of a number spinner. You see the value change live.changefires only after the user finishes editing — typically when the input loses focus, or for selects/checkboxes when the value commits.
const search = document.querySelector('#search');
search.addEventListener('input', (e) => {
// fires on every character — useful for live filtering, autocomplete
filter(e.target.value);
});
search.addEventListener('change', (e) => {
// fires once when the user blurs — useful for "save when done editing"
save(e.target.value);
});For text inputs, input is what you usually want for live feedback (filter as you type, validate as you type). change is what you want when partial values would be wasteful — you do not want to save a half-typed name on every keystroke.
For checkboxes and radios, change is the right event: it fires when the user actually flips the value. input also fires on these, but the semantics of change match the user's intent better.
For a search-as-you-type box, debounce the input handler — wait, say, 200ms after the last keystroke before searching. Otherwise you fire a request per character.
Reacting to invalid forms
The browser's built-in validation (the part you set with required, type="email", pattern, minlength, etc.) fires its own events.
invalidfires on a control when validation fails. By default the browser shows a small popup near the field. You can callevent.preventDefault()on theinvalidevent to suppress that and show your own UI.- The form's
submitevent still fires only when the form is valid. If validation blocks submission, yoursubmithandler does not run.
const email = document.querySelector('#email');
email.addEventListener('invalid', (event) => {
event.preventDefault(); // suppress the default popup
email.classList.add('is-invalid');
document.querySelector('#email-error').textContent = email.validationMessage;
});
email.addEventListener('input', () => {
// user is editing — clear the error state
email.classList.remove('is-invalid');
});element.validationMessage is the message the browser would have shown (e.g. "Please include an '@' in the email address"). It is already localized to the user's language. Reuse it; you will not write a better one.
If you want to validate without a submit attempt, call form.checkValidity() to test, or form.reportValidity() to test and show the messages.
submit handler never runs even though the user clicks the submit button. The form has required attributes on a few empty inputs. What's the most likely reason?Driving a dialog from JS
The <dialog> element is a real modal that the browser handles for you. It comes with focus management, an inert backdrop, and Escape-to-close — none of which you have to write yourself.
<dialog id="confirm"> <form method="dialog"> <p>Delete this item?</p> <button value="cancel">Cancel</button> <button value="delete">Delete</button> </form> </dialog> <button id="open-confirm">Delete</button>
const dialog = document.querySelector('#confirm');
const opener = document.querySelector('#open-confirm');
opener.addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'delete') {
actuallyDelete();
}
});showModal() opens the dialog as a real modal — focus moves into it, the rest of the page becomes inert, the backdrop blocks clicks. The user closes it by submitting a form with method="dialog" (which sets returnValue to the submitting button's value) or by pressing Escape.
The close event fires on the dialog when it closes, and dialog.returnValue is the button's value. So your code just reads returnValue to decide what happened — no extra event wiring per button.
A useful picture: a <dialog> is a tiny built-in modal. You hand the user a form inside it; the form's submit closes the dialog and tells you which button they pressed via returnValue. The browser does the framing; you do the meaning.
Use dialog.showModal(), not dialog.show(). show() opens it as a non-modal — the rest of the page stays interactive. Almost every confirm-style dialog wants modal behaviour.
submit handler does fetch('/save', { method: 'POST', body: new FormData(form) }). The fetch never completes — the network panel shows it as "(canceled)". What's wrong?