DOM and events · 7 / 10
lesson 7

Forms from JavaScript

Reading values with FormData, the submit event, validation events, and the dialog element from JS.

~ 16 min read·lesson 7 of 10
0 / 10

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.

submit.js
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.

Watch out

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.

formdata.js
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:

  1. It picks up every input with a name attribute. Inputs without name are ignored — they are decorative as far as forms are concerned.
  2. It works directly as a fetch body. Pass it as body: data and the browser sets the right Content-Type (including the boundary needed for file uploads) automatically.
  3. 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.

check your understanding
A signup form has <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.

  • input fires on every keystroke, every paste, every increment of a number spinner. You see the value change live.
  • change fires only after the user finishes editing — typically when the input loses focus, or for selects/checkboxes when the value commits.
input-vs-change.js
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.

Tip

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.

  • invalid fires on a control when validation fails. By default the browser shows a small popup near the field. You can call event.preventDefault() on the invalid event to suppress that and show your own UI.
  • The form's submit event still fires only when the form is valid. If validation blocks submission, your submit handler does not run.
validate.js
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.

check your understanding
Your 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.html
<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>
dialog.js
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.

Tip

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.

check your understanding
You want a confirmation dialog where the user can press Escape to cancel and Enter to confirm. Which approach gets that for free?
check your understanding
Your form's 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?
← prevnext lesson →
KeepLearningcertificate
for completing
DOM and events
0 of 10 read