JavaScript Practice #4 Working with Forms

In #3 fetch and Async UI you saw the patterns for fetching data and reflecting it on screen. This time we go the other direction — receiving user input, validating it, and sending it to the server.

Start with HTML built-in validation #

Before writing validation by hand in JavaScript, leverage HTML5 built-in validation first.

HTML built-in validation
<form id="signup">
  <input name="email" type="email" required>
  <input name="password" type="password" minlength="8" required>
  <input name="age" type="number" min="14" max="120">
  <input name="username" pattern="[a-z0-9_]{3,20}" required>
  <button type="submit">Sign up</button>
</form>

This alone — blocking empty fields, email format checks, length/range checks, regex patterns — all works. On submission, the browser validates automatically and even shows an error tooltip on the first invalid field.

It’s usually wasteful to reimplement what’s already built in. Keep the built-in as your foundation, and layer JavaScript on top as needed.

Leveraging built-in validation — the Constraint Validation API #

The tools to use built-in validation from JavaScript.

ValidityState
const input = document.querySelector('input[name="email"]');

input.validity.valid;          // all checks passed?
input.validity.valueMissing;    // required violation
input.validity.typeMismatch;    // type=email format violation
input.validity.tooShort;        // minlength violation
input.validity.patternMismatch; // pattern violation
// ... etc.

input.validationMessage;        // the message the browser would show

At the form level:

form level
form.checkValidity();    // is the whole form valid
form.reportValidity();   // validate and show tooltip if invalid

With this API you can leverage built-in validation while displaying your own UI messages.

Custom error messages #

If the default messages feel off — English-only, too terse, or poorly translated — you can set your own.

setCustomValidity
input.addEventListener('input', () => {
  if (input.value && !/^[a-z]+$/.test(input.value)) {
    input.setCustomValidity('Lowercase only');
  } else {
    input.setCustomValidity('');   // must be empty to be considered valid
  }
});

If setCustomValidity('a non-empty message') is set, the input is considered invalid. To clear the validation, set it back to an empty string.

Validation + display — the manual pattern #

If you don’t like the built-in tooltips, render the messages yourself. A common shape:

HTML
<form id="signup" novalidate>
  <label>
    Email
    <input name="email" type="email" required>
    <span class="error" data-for="email"></span>
  </label>

  <label>
    Password
    <input name="password" type="password" minlength="8" required>
    <span class="error" data-for="password"></span>
  </label>

  <button type="submit">Sign up</button>
</form>

The novalidate attribute is the key — it disables automatic browser validation. You handle everything.

Validation + display
function showError(name, message) {
  form.querySelector(`[data-for="${name}"]`).textContent = message;
}

function clearErrors() {
  form.querySelectorAll('.error').forEach((el) => (el.textContent = ''));
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  clearErrors();

  let hasError = false;
  for (const field of form.elements) {
    if (!field.name) continue;
    if (!field.checkValidity()) {
      showError(field.name, field.validationMessage);
      hasError = true;
    }
  }

  if (hasError) return;

  const formData = new FormData(form);
  submit(Object.fromEntries(formData));
});

JavaScript leverages built-in validation while your own markup handles the messages. This approach is the most common and robust.

FormData — collect form input at once #

The tool you saw in the React series works the same way in vanilla.

FormData
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const formData = new FormData(form);

  // Single field
  formData.get('email');           // 'me@example.com'

  // All key-value pairs
  for (const [key, value] of formData) {
    console.log(key, value);
  }

  // Convert to object
  const data = Object.fromEntries(formData);
  // { email: '...', password: '...', ... }

  await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
});

Pass the form whole into new FormData(form) and it auto-collects every input. Only fields with a name attribute are included.

Sending FormData as-is — multipart #

You can also send FormData directly as the fetch body without converting to JSON. This is the natural choice when there’s a file upload.

multipart body
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  await fetch('/api/upload', {
    method: 'POST',
    body: new FormData(form),
    // Don't set Content-Type yourself — the browser sets it with the boundary automatically
  });
});

The pattern you saw in Intermediate #6 fetch API.

Working with checkboxes / multi-select #

Multiple-value inputs
<input type="checkbox" name="tags" value="js">
<input type="checkbox" name="tags" value="ts">
<input type="checkbox" name="tags" value="react">

<select name="category" multiple>
  <option value="news">News</option>
  <option value="tip">Tip</option>
</select>
Receiving multiple values
const formData = new FormData(form);

formData.get('tags');         // first value only ('js')
formData.getAll('tags');      // ['js', 'ts'] — all selected
formData.getAll('category');  // every value of a multi-select

get returns the first value, getAll returns them all as an array. Always use getAll for checkbox groups to be safe.

Object.fromEntries(formData) keeps only the last value when a key appears multiple times, so for forms with multiple values you need to handle those keys directly.

Convert multi-value into an object
const data = {};
for (const key of new Set(formData.keys())) {
  const all = formData.getAll(key);
  data[key] = all.length > 1 ? all : all[0];
}

Common validation patterns #

1) Confirming two passwords match #

A case built-in validation can’t catch.

Password match
const password = form.elements.password;
const confirm = form.elements.passwordConfirm;

function syncPasswordMatch() {
  if (password.value !== confirm.value) {
    confirm.setCustomValidity('Passwords do not match');
  } else {
    confirm.setCustomValidity('');
  }
}

password.addEventListener('input', syncPasswordMatch);
confirm.addEventListener('input', syncPasswordMatch);

2) Async validation — checking for duplicate username #

A check that requires asking the server.

Async validation
const username = form.elements.username;

const checkUsername = debounce(async (value) => {
  if (!value) return;

  const res = await fetch(`/api/check-username?u=${value}`);
  const { available } = await res.json();

  username.setCustomValidity(available ? '' : 'Username already taken');
}, 400);

username.addEventListener('input', (e) => checkUsername(e.target.value));

The debounce pattern from #3 fetch and Async UI drops right in.

During submission — preventing duplicate submits #

disabled while submitting
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const submitBtn = form.querySelector('button[type="submit"]');
  submitBtn.disabled = true;

  try {
    const data = Object.fromEntries(new FormData(form));
    await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    showSuccess('Sign-up complete');
    form.reset();
  } catch (err) {
    showError(err.message);
  } finally {
    submitBtn.disabled = false;
  }
});

finally is the key — re-enables the button regardless of success or failure. Prevents accidents where the user submits the same form twice.

Form reset and initial values #

Form tools
form.reset();              // reset every field to its initial value
input.defaultValue;        // initial value (the one written in HTML)
input.value;                // current value

reset reverts to the initial value written in HTML (like <input value="...">). Values populated dynamically from JavaScript aren’t initial values — to clear them explicitly, use input.value = ''.

change vs input — which event do you listen to? #

Event differences
input    every keystroke / every change — real-time
change   on focus loss / on select change — change complete

For real-time validation, use input. For heavier work “after input is done,” use change or a debounced input.

Validation on blur #

Validation on blur
input.addEventListener('blur', () => {
  if (!input.checkValidity()) {
    input.classList.add('invalid');
    showError(input.name, input.validationMessage);
  } else {
    input.classList.remove('invalid');
    showError(input.name, '');
  }
});

This pattern skips validation while the user is still typing and only shows errors when focus leaves the field. Less annoying for users.

Accessibility — especially important for forms #

An accessible form
<label for="email">Email</label>
<input id="email" name="email" type="email" required
       aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>

Three keys:

  1. Use <label for="..."> or place the input inside a <label>
  2. Connect to the error region with aria-describedby
  3. Put role="alert" or aria-live="polite" on the error region so screen readers read changes

Built-in messages get accessibility automatically, but errors you render yourself need the setup above.

Wrapping up #

What this post covered:

  • Start with HTML built-in validation (required, type, pattern, min/max)
  • Leverage it from JavaScript via the ValidityState API
  • Custom messages with setCustomValidity
  • The novalidate + manual handling pattern is the most common
  • Collect form input at once with FormData
  • Send FormData as-is when files are involved (Content-Type set automatically)
  • Use getAll for multiple values (checkbox groups, multi-select)
  • Augmenting patterns like password match and async username validation
  • disabled during submit + restoration in finally
  • Accessibility — label, aria-describedby, role="alert"

The next post (#5 Local Storage and Lightweight State Management) covers tools for storing data in the browser and patterns for managing UI state cleanly without a library.

X