Working with forms in JavaScript

Read form values with FormData, take over submission without a page reload, and give clear, accessible validation feedback as people type.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Handle form submission
  • Validate input before sending
  • Read values with FormData
  • Show accessible error messages and move focus to the first problem

Before you start

HTML forms already collect and send data without any JavaScript, as the HTML course showed. JavaScript lets you go further: check the input before it is sent, explain problems while the person is still typing, and use the data on the page without reloading it. In this lesson the quiz app gets a form for adding your own questions.

The form

Add this section to quiz/index.html, inside main and after the quiz section:

HTML
<section aria-labelledby="add-heading">
  <h2 id="add-heading">Add your own question</h2>
  <form id="add-question" novalidate>
    <p>
      <label for="new-text">Question</label>
      <input id="new-text" name="text" type="text" required aria-describedby="new-text-error">
      <span id="new-text-error" class="error"></span>
    </p>
    <fieldset>
      <legend>Three possible answers</legend>
      <p>
        <label for="option-1">Answer 1</label>
        <input id="option-1" name="option" type="text" required aria-describedby="option-1-error">
        <span id="option-1-error" class="error"></span>
      </p>
      <p>
        <label for="option-2">Answer 2</label>
        <input id="option-2" name="option" type="text" required aria-describedby="option-2-error">
        <span id="option-2-error" class="error"></span>
      </p>
      <p>
        <label for="option-3">Answer 3</label>
        <input id="option-3" name="option" type="text" required aria-describedby="option-3-error">
        <span id="option-3-error" class="error"></span>
      </p>
    </fieldset>
    <p>
      <label for="correct">Which answer is correct?</label>
      <select id="correct" name="correct">
        <option value="0">Answer 1</option>
        <option value="1">Answer 2</option>
        <option value="2">Answer 3</option>
      </select>
    </p>
    <button type="submit">Add question</button>
    <p id="form-status" role="status"></p>
  </form>
</section>

Every field has a visible label, the three answers are grouped with fieldset and legend, and each field points at its own (initially empty) error message with aria-describedby. The required attributes still describe the rules, but novalidate turns off the browser's own error bubbles, because this form will show its own messages next to each field. If JavaScript fails to load, the browser would still send the form normally.

Handling submit

Listen for submit on the form, not click on the button. submit fires for a button click and for pressing Enter in a text field, so one listener covers both:

JavaScript
const addForm = document.querySelector("#add-question");
const formStatus = document.querySelector("#form-status");

addForm.addEventListener("submit", (event) => {
  event.preventDefault();
  console.log("Submitted without a reload");
});

preventDefault() stops the browser sending the form and loading a new page. From here, your code is in charge.

Reading values with FormData

You can read each field's value property one by one, but FormData collects the whole form at once, using each control's name:

JavaScript
const data = new FormData(addForm);

console.log(data.get("text"));      // → the question text
console.log(data.getAll("option")); // → ["let", "const", "var"] (all three answers)
console.log(data.get("correct"));   // → "1"

A few things to notice:

  • Fields without a name are not included, exactly as in a normal submission.
  • get returns the first value for a name; getAll returns every value as an array. That is why the three answers can share name="option".
  • Values are always strings. Convert numbers yourself with Number().
  • Object.fromEntries(data) turns a simple form into a plain object when each name appears once.

Validating

Validation answers one question per field: is this value acceptable, and if not, what should the person do? Start with a function that checks one field and shows or clears its message:

JavaScript
function setError(input, message) {
  const error = document.querySelector(`#${input.id}-error`);
  error.textContent = message;

  if (message) {
    input.setAttribute("aria-invalid", "true");
  } else {
    input.removeAttribute("aria-invalid");
  }
}

function validateField(input) {
  const label = input.labels[0].textContent;

  if (input.value.trim() === "") {
    setError(input, `Enter ${label.toLowerCase()}.`);
    return false;
  }
  setError(input, "");
  return true;
}

input.labels lists the labels connected to a field, so each message can name the field it belongs to: "Enter question." or "Enter answer 2.". Checking trim() also rejects a value of only spaces, which required alone would accept.

aria-invalid="true" tells screen readers the field has a problem, and because the message is referenced by aria-describedby, it is read out when the field gains focus. Style the invalid state from CSS with [aria-invalid="true"], and do not rely on colour alone: the message text is the main signal.

Some rules involve more than one field. The answers must all be different, or the quiz cannot tell which one is correct:

JavaScript
function validateForm() {
  const fields = Array.from(addForm.querySelectorAll("input"));
  const results = fields.map(validateField);

  const answers = Array.from(addForm.querySelectorAll('input[name="option"]'));
  const values = answers.map((input) => input.value.trim().toLowerCase());
  answers.forEach((input, i) => {
    if (values[i] && values.indexOf(values[i]) !== i) {
      setError(input, "Each answer must be different.");
      results[fields.indexOf(input)] = false;
    }
  });

  return fields.find((input, i) => !results[i]) || null;
}

validateForm checks every field, so all problems are shown at once rather than one per attempt, and returns the first invalid field or null.

Live feedback while typing

Showing errors before someone has finished typing is irritating. A good rule is: check a field when the person leaves it, then re-check it on every keystroke only while it is marked invalid, so the message disappears the moment it is fixed.

JavaScript
addForm.addEventListener("focusout", (event) => {
  if (event.target.matches("input")) validateField(event.target);
});

addForm.addEventListener("input", (event) => {
  if (event.target.getAttribute("aria-invalid") === "true") {
    validateField(event.target);
  }
});

These listeners sit on the form, and work for every field inside it because focusout and input bubble up from the field to the form. The blur event does not bubble, which is why focusout is used here. Lesson 29 explains bubbling and this delegation pattern in detail.

Putting submission together

Replace the earlier submit listener with this one:

JavaScript
addForm.addEventListener("submit", (event) => {
  event.preventDefault();

  const firstInvalid = validateForm();
  if (firstInvalid) {
    formStatus.textContent = "Please fix the errors in the form.";
    firstInvalid.focus();
    return;
  }

  const data = new FormData(addForm);
  const options = data.getAll("option").map((option) => option.trim());

  const question = {
    id: questions.length + 1,
    text: data.get("text").trim(),
    options,
    answer: options[Number(data.get("correct"))],
  };

  questions.push(question);
  addForm.reset();
  formStatus.textContent = `Question added. The quiz now has ${questions.length} questions.`;
});

The flow is the same for almost every form:

  1. Stop the default submission.
  2. Validate everything. If anything fails, say so in the status region and move focus to the first invalid field, so keyboard and screen reader users land on the problem with its message read out.
  3. Read the values, convert and tidy them, and build the data object.
  4. Use the data, reset the form, and confirm what happened.

The new question has the same { id, text, options, answer } shape as the others, and showQuestion reads questions.length each time, so the progress line counts it. If you have already finished the quiz, reload the page to play again; questions added this way live only in memory.

The questions array is a const, yet push works: const stops the variable being reassigned, not the array being changed.

Browser-side validation is for the user's convenience. It is not security. Anyone can skip your JavaScript and send any data to a server, so a server must always validate again.

Sending data without a reload

To save the question on a server instead of in memory, send it with fetch(). The page stays where it is, and you update it with the response:

JavaScript
async function saveQuestion(question) {
  const response = await fetch("/api/questions", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(question),
  });

  if (!response.ok) {
    throw new Error(`Saving failed with status ${response.status}`);
  }
  return response.json();
}

This only works if your own server has an endpoint at /api/questions; the quiz does not have one, so treat this as the shape to come back to. async, await, JSON.stringify and fetch() are covered properly in lessons 26 and 31 to 33. You can also pass a FormData object directly as the body, and the browser sends it encoded exactly as a normal form submission would be.

Summary

  • Listen for submit on the form and call event.preventDefault() to handle it in JavaScript.
  • new FormData(form) reads every named field; use get for one value and getAll for repeated names. Values are strings.
  • Validate each field, show messages next to fields with aria-describedby and aria-invalid, and check cross-field rules too.
  • Validate when a field is left, then live while it is invalid; on submit, show every error and focus the first invalid field.
  • Client-side validation helps users, but servers must validate again; fetch() sends data without a reload.

Further reading