Handling a Form in JavaScript
The browser's built-in checks are enough for many forms. Sometimes a page wants more: a rule no attribute can express, a message in its own words beside the field, or a result shown without leaving the page. JavaScript can take over the moment a form is submitted and do all of that. The condition is that it keeps everything the browser was already doing for everyone: the keyboard, the labels, the built-in checks.
Think of a receptionist who looks over your paper form at the counter before it goes into the tray. They spot the missing signature while you are still standing there, which is kinder than a rejection in the post a week later. But the receptionist is not the office that processes the form. A script in the page is the receptionist, and this page ends with the site's contact form admitting exactly that.
Listening for submit
A form fires a submit event when it is really being sent, whether the visitor pressed the button or pressed Enter in a field. It fires only after the browser's own checks have passed. So the listener goes on the form element, not on the button.
const form = document.querySelector("#contact-form"); if (form) { form.addEventListener("submit", (event) => { event.preventDefault(); handleContact(form); }); } function handleContact(form) { const data = Object.fromEntries(new FormData(form)); console.log(data); }
The first line finds the form. The if is the guard from Chapter 8: main.js runs on all three pages, and only the Contact page has this form. The listener receives the event object from Chapter 9 and calls its preventDefault method, which stops the browser's default action, the thing it would have done anyway. For a submit, that is sending the form and loading a new page. Then it hands the form to a function of your own, handleContact.
Fill in the three fields on the Contact page and press the button. The page stays where it is, and the Console shows what the form contained. Now leave a field empty and press it again: the browser's bubble appears as before, and nothing reaches the Console. The built-in checks ran first, failed, and the submit event never fired.
Two mistakes are worth seeing once. Forget preventDefault and your code runs, then the page reloads a moment later and wipes out whatever it showed: a message that flashes and vanishes is almost always this. And put the listener on the button's click instead: a click happens before the browser checks anything, so the listener runs even when required fields are empty.
Reading the Fields with FormData
new FormData(form) collects every named field of a form, the same pairs the browser would have sent. Object.fromEntries turns those pairs into an ordinary object from Chapter 7, with each field's name as a key. Here is what the Console shows for a filled-in form.
{name: 'Your Name', email: 'you@example.com', message: 'I liked your Recipe Cards project.'}
Every value is a string, even one typed into a number field, so the text-and-number traps of Chapter 6 apply here too. And the first topic's rule still holds: a field without a name is simply missing from the object.
A Rule the Attributes Cannot Express
The site wants messages with some substance: at least 20 characters. The attribute minlength="20" looks like the answer, but it counts every character, so twenty presses of the space bar pass. And pattern does not work on a textarea at all. This rule needs a script.
The page will write its own message next to the field. Two attributes connect that message to the field for screen readers. aria-describedby on the field holds the id of the element with the message, and a screen reader reads that element after the field's label. aria-invalid="true" tells it the current value is wrong. Both are ARIA attributes, extra information for assistive technology, which Chapter 12 explains properly.
<label for="message">Message</label> <textarea id="message" name="message" rows="6" required aria-describedby="message-error"></textarea> <p id="message-error"></p> <button type="submit">Prepare email</button> </form> <p id="form-status" role="status"></p> <p id="message-copy"></p> <a id="mail-link" hidden>Send it from your email app</a>
This is the end of the contact form, from the message field down, with four new elements. The empty paragraph under the message box will hold the error. The three elements after the form hold the result, and the rest of the page uses them. The button's words change too, for a reason the last section gives.
function checkMessage(field, errorText) { if (field.value.trim().length < 20) { errorText.textContent = "Please write at least 20 characters."; field.setAttribute("aria-invalid", "true"); field.focus(); return false; } errorText.textContent = ""; field.removeAttribute("aria-invalid"); return true; }
trim removes spaces from both ends of a string, so only real text is counted. If fewer than 20 characters remain, the function writes the message into the error paragraph, marks the field invalid, moves the cursor back into it, and returns false. Otherwise it clears both and returns true. Type "Hi there" and press the button: the words Please write at least 20 characters appear under the message box, and the cursor is back in it. A screen reader announces the field's label, that it is invalid, and the message.
DevTools confirms it. Select the message box in the Elements panel and open the Accessibility tab: the field's Description line is now the error text, and Invalid user entry says true.
There is a second tool for custom rules, setCustomValidity. Give a field a message with it and the browser treats that field as failing, showing your words in its own bubble on the next submit. An empty string makes the field valid again. Forget to clear it, and the field fails forever and the form can never be sent. The site uses its own paragraph instead, because its words stay on screen.
Announcing the Result
When the form passes, the page shows a result. A sighted visitor sees new text appear. A screen-reader user whose focus is still on the button hears nothing, unless the page says so in a way their software notices.
That is what role="status" on the form-status paragraph does. It makes the paragraph a status region: whenever its text changes, screen readers read the new text aloud, politely, once they finish what they were saying, and without moving the visitor's focus. The region must already be in the page, empty, before its text changes. A status element added at the last moment is often not announced at all.
Where Does the Message Honestly Go?
The site has no server, as the first topic of this chapter explained, so the form cannot deliver anything. A status that said "Thanks, your message has been sent" would be a lie. The honest version checks the message, shows it back, and offers a link that opens the visitor's own email program with everything filled in. The button now says Prepare email, because that is what it does.
A mailto: link opens the visitor's email program, and the address after it can carry a subject and a body. Text inside an address must be encoded: spaces, line breaks, ampersands and question marks all have special meanings there. encodeURIComponent turns each of them into a safe code, so a message containing an ampersand arrives whole instead of cut off at it.
function handleContact(form) { const data = Object.fromEntries(new FormData(form)); const field = form.querySelector("#message"); if (!checkMessage(field, form.querySelector("#message-error"))) { return; } const subject = encodeURIComponent(`Message from ${data.name}`); const body = encodeURIComponent(data.message); const link = document.querySelector("#mail-link"); link.href = `mailto:you@example.com?subject=${subject}&body=${body}`; link.hidden = false; document.querySelector("#message-copy").textContent = data.message; document.querySelector("#form-status").textContent = "Your message is ready. This site cannot send it, so use the link below."; }
This replaces the first version of handleContact. It reads the form, and if the message check fails it stops there. Otherwise it builds the link's address from the visitor's name and message, reveals the link, shows the message back, and updates the status region. Put your own address in place of the example one. Fill in the form properly and press the button: under the form appear the sentence Your message is ready, a copy of your words, and the link. A screen reader speaks the sentence. Click the link and your email program opens a new message with the address, subject and text already in place.
The copy of the message is set with textContent, Chapter 8's rule for anything a visitor typed: whatever they wrote, even something that looks like HTML, is shown as plain text and never run.
To deliver messages without the visitor's email program, something must receive them. There are two real options. One is a server of your own, which Backend Deep Dive builds. The other is a hosted form service: a company that runs the receiving program for you, so your form's action points at their address. Both are choices about cost and about who holds your visitors' messages, and neither is part of this book.
- "Listen for the button's click." A click happens before the browser's checks, so a click listener runs even for an invalid form.
submitfires only when the form is really being sent. - "With preventDefault, my own checks are all that run." The browser's built-in checks run before
submitfires and still apply. Rules they cannot express have to be added by you. - "Showing a success message means it was sent." Only if something actually received it. The interface must say what really happened.
- The site's Contact page is complete: accessible, checked, and honest about what it does.
- The same pattern handles every form you will build with a server behind it; only the last step changes.
Knowledge Check
Why does the site listen for the form's submit event rather than the button's click?
- Buttons inside a form cannot have click listeners
- The click event does not carry any event object with it
- A submit listener runs faster than a click listener
- It fires only when the form is really being sent
What does event.preventDefault() stop inside the submit listener?
- The browser sending the form and loading a page
- The browser's built-in checks on required fields
- Any other listener from running on the same form
- The visitor typing into the form's fields again
How does the error text under the message box reach a screen reader?
- The error paragraph sits right after the field
- The field's
aria-describedbynames the error's id - The error text is red, and colours are announced
- Screen readers read every new paragraph on arrival
The contact form passes every check. What can the site, which has no server, honestly do with the message?
- Send it to the owner's inbox straight from the page
- Save it on the server in a file beside the Contact page
- Offer a link that opens it in the visitor's email app
- Show the words Message sent once all checks have passed
You got correct