Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 6

Forms

How native forms work and how to add validation, accessible error messages, and submission states on top of them, building an event registration form with a fake async submit.

A single form touches semantics, events, state, validation, error messages, accessibility, and waiting on a server all at once. Think about someone registering for a conference on their phone. They type their email with a typo and move on. They skip the attendance question because it was below the fold. They press Register, nothing seems to happen for a second, so they press it again. The network drops and the request fails.

A good registration form handles every one of those moments. It points out the typo after they leave the field, without shouting at them while they’re still typing. When they submit, it lists what’s missing and takes them straight to it. It ignores the second press and shows that it’s working. When the request fails, it says so plainly and keeps everything they typed. This chapter builds that form, starting from what the browser already does for free.

What a form needs to remember

Run the form through the introduction’s four questions and the state comes out clearly.

  1. What state does it remember? The values the person has entered, which fields have errors and what they say, and whether a submission is in progress. Later you might also track which fields have been visited and whether anything has changed.
  2. What events change it? Typing (input), leaving a field (focusout), choosing an option (change), submitting (submit), and the server’s answer arriving.
  3. What updates on screen? Each field’s error message and aria-invalid state, the error summary, the submit button’s label, a status message, and finally the confirmation view.
  4. How does it stay usable and resilient? Errors are connected to their fields so screen readers read them, a double submit does nothing, a failure keeps the person’s answers, and the server checks everything again.

The values themselves are one piece of state you mostly don’t copy into JavaScript. The form controls already hold them, and FormData reads them when you need them. Keeping a second copy in a state object gives you two sources of truth to keep in sync, so this chapter’s state holds only what the controls can’t: errors and submission status.

Native form behavior

A <form> with named controls and a submit button already does a lot without any script. Pressing Enter in a text field submits it. The submit button submits it. Each control’s name becomes a key in the submitted data. Labels connected with for make the label text clickable and give each control its accessible name. If the script fails to load, a form with an action still posts to the server, and the server can respond.

JavaScript joins in through the submit event. Listening on the form, rather than for clicks on the button, catches every way of submitting: the button, the Enter key, and assistive technology. Call event.preventDefault() to stop the browser’s own submission so you can validate and send the data yourself.

If you ever need to submit a form from code, use form.requestSubmit(). It runs validation and fires the submit event as if someone had pressed the button. The older form.submit() skips both, so your validation and your submit listener never run.

Reading values with FormData

new FormData(form) collects the current value of every named, enabled control in the form.

const data = new FormData(form);
data.get('email');      // 'sam@example.com'
data.get('ticket');     // value of the checked radio, such as 'student'
data.get('attendance'); // value of the selected option
data.getAll('dietary'); // every checked checkbox value, such as ['vegan', 'gluten-free']

Different controls behave in ways worth knowing:

  • A text field, email field, or textarea always appears, even when empty, as an empty string.
  • A radio group gives the value of the checked radio, or doesn’t appear at all if none is checked, so get returns null.
  • A checkbox appears only when it’s checked. Several checkboxes with the same name produce several entries, and getAll returns them as an array.
  • A select gives the value of the selected option.
  • Disabled controls and controls without a name are left out entirely.

You’ll also see Object.fromEntries(new FormData(form)), which turns the data into a plain object. It keeps only one value per name, so it loses all but one checked checkbox in a group.

Native validation and custom messages

The browser has a constraint validation API built in. Attributes like required, type="email", minlength, maxlength, and pattern describe the rules, and each control reports whether it passes through its validity property.

email.validity.valid;         // true when every rule passes
email.validity.valueMissing;  // true when required and empty
email.validity.typeMismatch;  // true when the text isn't an email address
email.checkValidity();        // returns the same answer as validity.valid
email.validationMessage;      // the browser's own message, in the browser's language

Without any script, the browser blocks submission and shows a bubble next to the first invalid field. Those bubbles are a reasonable fallback, but you can’t control their wording, placement, or styling, they show one problem at a time, and they disappear after a few seconds. Most product forms add novalidate to the <form> to turn the bubbles off and show their own messages instead. novalidate only stops the browser from blocking submission and showing bubbles. The rules still apply, and validity still reports on them, so you can keep the attributes and use them as the source of your rules.

For a rule HTML can’t express, such as “the two passwords must match,” you can either check it in your own function or call field.setCustomValidity("Passwords don't match."), which marks the field invalid with that message. A custom validity message stays until you clear it with setCustomValidity(''), so a field you forget to clear stays invalid forever.

Native type="email" checking is loose on purpose. It accepts sam@localhost and doesn’t know whether the mailbox exists. It catches typos like a missing @, which is what it’s for.

When to show errors

The timing of an error matters as much as its wording. An error that appears while someone is halfway through typing their email tells them something they already know, and it feels like being corrected mid-sentence. An error that waits until they submit makes them scroll back to find it. The pattern that works well follows four rules:

  • Validate a field when it loses focus (blur or change) the first time.
  • Once a field is showing an error, re-validate it on every input, so the error clears as soon as the value is fixed.
  • Validate every field on submit.
  • Never show an error on the first keystroke.

The second rule is what makes this feel helpful. When someone goes back to fix a field, they see the message disappear the moment their value becomes valid, which confirms they fixed it. If they then edit a valid field into an invalid one, the error waits until they leave the field again.

blur and focus don’t bubble, so a single listener on the form never hears them. Their bubbling equivalents, focusout and focusin, do. Use blur on a single field, and focusout when one listener on the form handles every field.

Error messages that reach everyone

A red border and some red text below a field are visible to sighted people, but not connected to the field for anyone else. Two attributes make the connection.

  • aria-describedby on the control points to the id of the error element, so a screen reader reads the error text after the label when the field gets focus.
  • aria-invalid="true" tells assistive technology the current value is invalid. Screen readers typically announce it as “invalid entry” or similar.
<label for="email">Email</label>
<input id="email" name="email" type="email" required
       aria-describedby="email-hint email-error">
<p id="email-hint">We'll send your ticket here.</p>
<p id="email-error" data-error-for="email" hidden></p>

aria-describedby can list several ids, and they’re read in order. Leave the error element in place and empty when there’s no error, so the description is just the hint. Set its text and remove hidden when there is one. Remove aria-invalid entirely when the field is valid, rather than leaving it in place as "false".

Write the message as an instruction: “Enter an email address in the format name@example.com” tells someone what to do, while “Invalid email” only tells them they got it wrong. Pair it with something besides color, such as an icon or the word “Error,” so people who can’t distinguish the red still notice it.

The error summary

On submit, a long form can have problems far above or below the button. An error summary at the top of the form lists every problem as a link to its field, and receives focus so a screen reader reads it immediately.

<div data-error-summary tabindex="-1" hidden>
  <h2 data-error-summary-heading></h2>
  <ul data-error-summary-list></ul>
</div>

tabindex="-1" lets script focus the container without adding it to the Tab order. Each link points at a field’s id. Following an in-page link scrolls to the target, and the HTML standard says it should also focus a target that can take focus. The project still handles the click and focuses the field itself, so the result doesn’t depend on the browser.

Touched, dirty, and submitting

Forms often track a few more pieces of state.

  • Touched means the person has visited a field and left it. The validation timing above is a touched rule: a field doesn’t show errors until it has been touched or the form has been submitted.
  • Dirty means a value differs from what it started as. It decides things like enabling Save only when something changed, which you built in chapter 2’s settings page, or warning before someone navigates away from unsaved work.
  • Submitting means a request is in flight. While it’s true, the form should show it’s working and ignore further submissions.

The double-submit guard is a single line at the top of the submit handler: if (state.submitting) return;. That line is the real protection, because it works no matter how the form was submitted.

You might expect to disable the submit button as well. A disabled button can’t be focused, so if focus was on it, focus is lost, and a screen reader user can’t find the button to hear that it’s busy. A gentler option is aria-disabled="true", which announces the button as unavailable while leaving it focusable, together with a visible label change to “Registering…” and a status message. Because aria-disabled doesn’t stop clicks, it only works alongside the guard.

Disabled and readonly

The two attributes look similar but do different things.

  • A disabled control can’t be focused or edited, and its value is not included in FormData or the submission.
  • A readonly control can be focused and selected but not edited, and its value is submitted. It applies to text-like inputs and textareas, not to checkboxes, radios, or selects.

Use readonly for a value someone should see and copy but not change, such as an order number. Use disabled when a control genuinely doesn’t apply right now, and explain why nearby.

Client validation is a convenience

Everything in this chapter runs in the person’s browser, and anyone can change or skip it with developer tools, or by sending a request without the page at all. Client-side validation exists to give fast, friendly feedback. The server must check every rule again, and some rules only the server can check, such as whether an email address is already registered. The project’s fake server returns that kind of error, and the form shows it in the same place and the same way as its own.

Smallest version: one well-timed field

This page has a single email field that follows the timing rules.

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Sign up</title>
  <script type="module" src="app.js"></script>
</head>
<body>
  <form data-signup-form novalidate>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required
           aria-describedby="email-error">
    <p id="email-error" data-error hidden></p>
    <button type="submit">Sign up</button>
  </form>
  <p role="status" data-result></p>
</body>
</html>
// app.js
const form = document.querySelector('[data-signup-form]');
const email = form.elements.email;
const error = document.querySelector('[data-error]');
const result = document.querySelector('[data-result]');
const state = { emailError: '' };

function getEmailError() {
  if (email.validity.valueMissing) return 'Enter your email address.';
  if (email.validity.typeMismatch) {
    return 'Enter an email address in the format name@example.com.';
  }
  return '';
}

function validateEmail() {
  state.emailError = getEmailError();
  error.textContent = state.emailError;
  error.hidden = state.emailError === '';
  if (state.emailError) {
    email.setAttribute('aria-invalid', 'true');
  } else {
    email.removeAttribute('aria-invalid');
  }
}

email.addEventListener('blur', validateEmail);

email.addEventListener('input', () => {
  if (state.emailError === '') return;
  validateEmail();
});

form.addEventListener('submit', (event) => {
  event.preventDefault();
  validateEmail();
  if (state.emailError) {
    email.focus();
    return;
  }
  result.textContent = `Thanks. We'll write to ${email.value}.`;
});

Try it: type “sam” and nothing happens. Press Tab and the format error appears. Shift+Tab back, type “@example.com,” and the error disappears on the keystroke that makes the address valid.

Accessibility check

Use the smallest version with a screen reader running (VoiceOver with Command+F5 on a Mac, or NVDA on Windows). Type “sam,” press Tab, then Shift+Tab back to the field. You should hear the label, “invalid entry” or similar, and the error text. Now delete aria-describedby from the input and try again. The field is still marked invalid, but the screen reader no longer reads what’s wrong, so the person knows there’s a problem without knowing how to fix it. Put the attribute back.

Next, in the project, leave every field empty and press Register. Focus should move to the error summary, and the screen reader should read its heading and list. Activate a link and confirm focus lands in that field, with its error read as the description. Finally, submit successfully and confirm focus moves to the confirmation heading. Without that, the form disappears from under the person’s focus and they’re left on nothing.

Debugging challenge: errors while typing

Remove the guard clause from the input listener in the smallest version:

email.addEventListener('input', () => {
  validateEmail();
});

The symptom: reload, click into the empty field, and type one letter. The error “Enter an email address in the format name@example.com” appears immediately, while the person is still typing.

Start by finding which code wrote the message. In Chrome or Edge, select the input in the Elements panel and open the Event Listeners tab to see every listener attached to it: blur and input (Firefox shows an “event” badge next to the element in its Inspector). The error showed up without a blur, so the input listener is the suspect. Set a breakpoint on its first line in the Sources panel, or add console.log('input', state.emailError), then type a letter. The listener runs and calls validateEmail even though state.emailError was empty, meaning no error was showing yet.

The rule says to re-validate on input only once a field is already showing an error. Restore the guard, if (state.emailError === '') return;, so the first keystroke does nothing and the blur listener decides when the first error appears.

This works because…

This works because the form’s values stay in the form controls and I read them with FormData when I need them, while my state object only remembers what the controls can’t: each field’s current error and whether a submission is in progress. Fields are checked when someone leaves them, re-checked on every keystroke once they show an error, and all checked on submit, using the browser’s own validity rules with my own messages. Each error is connected to its field with aria-describedby and aria-invalid, so a screen reader reads it. The submitting flag makes a second submit do nothing, and a failure leaves every answer in place.

Project: event registration form

Build a registration form for a one-day conference. It asks for full name, email, ticket type, how the person will attend, dietary needs, and accessibility accommodations. It validates with the timing rules, shows an error summary on submit, prevents duplicate submissions, shows server-side errors, survives a failed request, and ends on a confirmation view.

Starter HTML

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Register for Interface Day</title>
  <style>
    [aria-invalid="true"] { border: 2px solid #b00020; }
    [data-error-for]::before { content: "Error: "; font-weight: bold; }
    [aria-disabled="true"] { opacity: 0.6; cursor: progress; }
  </style>
  <script type="module" src="app.js"></script>
</head>
<body>
  <main>
    <h1>Register for Interface Day</h1>

    <form data-registration-form novalidate>
      <div data-error-summary tabindex="-1" hidden>
        <h2 data-error-summary-heading></h2>
        <ul data-error-summary-list></ul>
      </div>

      <div>
        <label for="fullName">Full name</label>
        <input id="fullName" name="fullName" type="text" autocomplete="name"
               required aria-describedby="fullName-error">
        <p id="fullName-error" data-error-for="fullName" hidden></p>
      </div>

      <div>
        <label for="email">Email</label>
        <input id="email" name="email" type="email" autocomplete="email"
               required aria-describedby="email-hint email-error">
        <p id="email-hint">We'll send your ticket here.</p>
        <p id="email-error" data-error-for="email" hidden></p>
      </div>

      <fieldset>
        <legend>Ticket type</legend>
        <label><input type="radio" name="ticket" value="general" checked> General admission</label>
        <label><input type="radio" name="ticket" value="student"> Student</label>
        <label><input type="radio" name="ticket" value="workshop"> Conference and workshop</label>
      </fieldset>

      <div>
        <label for="attendance">How will you attend?</label>
        <select id="attendance" name="attendance" required
                aria-describedby="attendance-error">
          <option value="">Choose one</option>
          <option value="in-person">In person</option>
          <option value="online">Online</option>
        </select>
        <p id="attendance-error" data-error-for="attendance" hidden></p>
      </div>

      <fieldset>
        <legend>Dietary needs (optional)</legend>
        <label><input type="checkbox" name="dietary" value="vegetarian"> Vegetarian</label>
        <label><input type="checkbox" name="dietary" value="vegan"> Vegan</label>
        <label><input type="checkbox" name="dietary" value="gluten-free"> Gluten-free</label>
      </fieldset>

      <div>
        <label for="accommodations">Accessibility accommodations (optional)</label>
        <textarea id="accommodations" name="accommodations" rows="3"></textarea>
      </div>

      <p role="status" data-form-status></p>
      <button type="submit" data-submit>Register</button>
    </form>

    <section data-confirmation hidden>
      <h2 tabindex="-1" data-confirmation-heading>You're registered</h2>
      <p data-confirmation-message></p>
      <dl data-confirmation-details></dl>
    </section>
  </main>
</body>
</html>

The ticket radios start with “General admission” checked, since most people choose it and a radio group with a sensible default can’t be left empty. The ::before rule adds the word “Error” to each message so it doesn’t rely on color.

The fake server

This stands in for a real endpoint. It waits a moment, then answers in one of three ways depending on the email address, so you can test each path on purpose.

// fake-server.js
export function fakeRegister(data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const email = data.get('email');
      if (email.endsWith('@fail.test')) {
        reject(new Error('Network error'));
        return;
      }
      if (email === 'taken@example.com') {
        resolve({
          ok: false,
          fieldErrors: { email: 'That email address is already registered.' },
        });
        return;
      }
      resolve({ ok: true, reference: `ID-${Math.floor(Math.random() * 90000) + 10000}` });
    }, 1500);
  });
}

Chapter 7 explains promises properly. For now, await fakeRegister(data) pauses the submit handler until the fake server answers, without freezing the page, and a rejected promise jumps to the catch block.

Key JavaScript

// app.js
import { fakeRegister } from './fake-server.js';

const validatedFields = ['fullName', 'email', 'attendance'];
const messages = {
  fullName: { valueMissing: 'Enter your full name.' },
  email: {
    valueMissing: 'Enter your email address.',
    typeMismatch: 'Enter an email address in the format name@example.com.',
  },
  attendance: { valueMissing: 'Choose how you will attend.' },
};
const ticketNames = {
  general: 'General admission',
  student: 'Student',
  workshop: 'Conference and workshop',
};
const attendanceNames = { 'in-person': 'In person', online: 'Online' };

const state = { errors: {}, submitting: false };

const form = document.querySelector('[data-registration-form]');
const summary = document.querySelector('[data-error-summary]');
const summaryHeading = document.querySelector('[data-error-summary-heading]');
const summaryList = document.querySelector('[data-error-summary-list]');
const formStatus = document.querySelector('[data-form-status]');
const submitButton = document.querySelector('[data-submit]');
const confirmation = document.querySelector('[data-confirmation]');
const confirmationHeading = document.querySelector('[data-confirmation-heading]');
const confirmationMessage = document.querySelector('[data-confirmation-message]');
const confirmationDetails = document.querySelector('[data-confirmation-details]');

// Validation

function isValidatedField(element) {
  return validatedFields.includes(element.name);
}

function getErrorMessage(field) {
  if (field.validity.valid) return '';
  const fieldMessages = messages[field.name] ?? {};
  for (const problem in fieldMessages) {
    if (field.validity[problem]) return fieldMessages[problem];
  }
  return field.validationMessage;
}

function showFieldError(field, message) {
  state.errors[field.name] = message;
  const error = form.querySelector(`[data-error-for="${field.name}"]`);
  error.textContent = message;
  error.hidden = message === '';
  if (message) {
    field.setAttribute('aria-invalid', 'true');
  } else {
    field.removeAttribute('aria-invalid');
  }
}

function validateField(field) {
  const message = getErrorMessage(field);
  showFieldError(field, message);
  return message;
}

// Error summary

function showErrorSummary(problems) {
  summaryHeading.textContent =
    problems.length === 1 ? 'There is 1 problem' : `There are ${problems.length} problems`;
  const items = problems.map((problem) => {
    const item = document.createElement('li');
    const link = document.createElement('a');
    link.href = `#${problem.field.id}`;
    link.dataset.field = problem.field.name;
    link.textContent = problem.message;
    item.append(link);
    return item;
  });
  summaryList.replaceChildren(...items);
  summary.hidden = false;
  summary.focus();
}

function hideErrorSummary() {
  summary.hidden = true;
  summaryList.replaceChildren();
}

// Submission

function setSubmitting(isSubmitting) {
  state.submitting = isSubmitting;
  submitButton.setAttribute('aria-disabled', String(isSubmitting));
  submitButton.textContent = isSubmitting ? 'Registering…' : 'Register';
  formStatus.textContent = isSubmitting ? 'Sending your registration…' : '';
}

function showConfirmation(data, reference) {
  const dietary = data.getAll('dietary').join(', ');
  const accommodations = data.get('accommodations').trim();
  const details = [
    ['Name', data.get('fullName')],
    ['Ticket', ticketNames[data.get('ticket')]],
    ['Attendance', attendanceNames[data.get('attendance')]],
    ['Dietary needs', dietary || 'None'],
    ['Accommodations', accommodations || 'None'],
  ];
  confirmationMessage.textContent =
    `We've sent your ticket to ${data.get('email')}. Your reference is ${reference}.`;
  confirmationDetails.replaceChildren();
  details.forEach((row) => {
    const term = document.createElement('dt');
    const value = document.createElement('dd');
    term.textContent = row[0];
    value.textContent = row[1];
    confirmationDetails.append(term, value);
  });
  form.hidden = true;
  confirmation.hidden = false;
  confirmationHeading.focus();
}

// Events

form.addEventListener('focusout', (event) => {
  const field = event.target;
  if (!isValidatedField(field)) return;
  validateField(field);
});

form.addEventListener('input', (event) => {
  const field = event.target;
  if (!isValidatedField(field)) return;
  if (!state.errors[field.name]) return;
  validateField(field);
});

summaryList.addEventListener('click', (event) => {
  const link = event.target.closest('a');
  if (!link) return;
  event.preventDefault();
  form.elements[link.dataset.field].focus();
});

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (state.submitting) return;

  const problems = validatedFields
    .map((name) => form.elements[name])
    .map((field) => ({ field, message: validateField(field) }))
    .filter((problem) => problem.message !== '');

  if (problems.length > 0) {
    showErrorSummary(problems);
    return;
  }
  hideErrorSummary();

  const data = new FormData(form);
  setSubmitting(true);
  try {
    const result = await fakeRegister(data);
    setSubmitting(false);
    if (result.ok) {
      showConfirmation(data, result.reference);
      return;
    }
    const serverProblems = Object.keys(result.fieldErrors).map((name) => {
      const field = form.elements[name];
      showFieldError(field, result.fieldErrors[name]);
      return { field, message: result.fieldErrors[name] };
    });
    showErrorSummary(serverProblems);
  } catch {
    setSubmitting(false);
    formStatus.textContent =
      "We couldn't send your registration. Your answers are still here, so you can try again.";
  }
});

A few details are worth reading closely. ({ field, message: validateField(field) }) is an object written in shorthand, wrapped in parentheses so the arrow returns it instead of treating the braces as a function body. Every field is validated before the summary is built, so each one also shows its inline error. catch without a parameter is allowed when you don’t need the error object.

A server error goes through showFieldError, the same function the client uses, so it looks the same, is connected to the field the same way, and clears the same way once the person edits the field. The failure message goes in the status region that has been on the page since it loaded, so it’s announced, and nothing in the form is cleared.

The error summary isn’t updated as people fix fields, only on the next submit. That keeps the summary from changing under someone who is reading it, and the inline errors already clear as they go.

Done means

  • Every control has a visible label, and related radios and checkboxes are grouped with <fieldset> and <legend>.
  • Errors appear when a field loses focus, clear on the keystroke that fixes them, all appear on submit, and never appear on a first keystroke.
  • Each error is connected with aria-describedby, the field has aria-invalid="true" only while invalid, and the message says how to fix the problem.
  • Submitting with errors focuses an error summary whose links move focus into the matching field.
  • Pressing Register several times during a submission sends one request. Test it by adding a console.log in fakeRegister.
  • taken@example.com shows a server error on the email field in the same style as client errors.
  • An address ending in @fail.test shows a failure message that a screen reader announces, and every answer is still in the form.
  • Success shows a confirmation with the submitted details, rendered with textContent, and moves focus to its heading.
  • Everything works with only a keyboard, and you’ve checked the flow with a screen reader.
  • You’ve written a “this works because…” paragraph that explains the validation timing, where each piece of state lives, and why the server still has to validate.

Chapter 7 looks at real fetch requests, and at what an interface should say while it waits, when the request fails, and when the answer is empty.

Found an error? Report it.