Skip to content

Form Validation

This lesson explains how to validate form-associated custom elements using ElementInternals, the Constraint Validation API, custom error messages, and accessible validation feedback.

Why Form Validation Matters in Web Components

Custom form controls must behave like native inputs: block invalid submission, expose validation state to the parent form, and show helpful error messages. Without validation integration, a custom element looks like a field but does not participate in form behavior.

Form-associated custom elements use ElementInternals to report validity through the same browser APIs that power native <input> and <select> elements.

Concept Role in Validation
formAssociated Opts the element into form participation
attachInternals() Provides the validation and form APIs
setValidity() Reports valid or invalid state to the form
checkValidity() Checks validity without showing UI
reportValidity() Checks validity and shows the error message
ValidityState Flags such as valueMissing and customError

Basic Validated Custom Input

class AppEmailInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <input type="email" part="input" />
      <span part="error" hidden></span>
    `;

    this.input = this.shadowRoot.querySelector("input");
    this.error = this.shadowRoot.querySelector("[part=error]");

    this.input.addEventListener("input", () => this.validate());
    this.input.addEventListener("blur", () => this.validate(true));
  }

  validate(showMessage = false) {
    const value = this.input.value.trim();
    const isValid = value.includes("@");

    this.internals.setValidity(
      { customError: !isValid },
      isValid ? "" : "Enter a valid email address.",
      this.input
    );

    this.internals.setFormValue(value);
    this.error.hidden = isValid;
    this.error.textContent = isValid ? "" : "Enter a valid email address.";
    this.input.setAttribute("aria-invalid", String(!isValid));

    if (showMessage && !isValid) {
      this.internals.reportValidity();
    }
  }
}

customElements.define("app-email-input", AppEmailInput);
<form>
  <app-email-input name="email" required></app-email-input>
  <button type="submit">Save</button>
</form>

The component validates on input, reports state through setValidity(), and participates in native form submission.

Using setValidity()

this.internals.setValidity(
  { customError: true },
  "Username must be at least 3 characters.",
  this.input
);

setValidity() takes three arguments: a ValidityState flags object, a custom error message, and an optional anchor element for focus when reportValidity() runs.

Validity Flag Meaning
valueMissing Required field is empty
typeMismatch Value does not match expected type
patternMismatch Value fails a pattern rule
tooShort / tooLong Length constraints failed
rangeUnderflow / rangeOverflow Numeric min/max failed
customError Your own validation rule failed

Clearing Validation Errors

if (isValid) {
  this.internals.setValidity({});
  this.error.hidden = true;
  this.input.removeAttribute("aria-invalid");
}

Pass an empty flags object to mark the control valid again. Always clear visual and ARIA error state when validation passes.

checkValidity() vs reportValidity()

Method Behavior Typical Use
element.checkValidity() Returns true or false silently Pre-submit checks in JavaScript
element.reportValidity() Returns result and shows message User-facing validation on submit or blur
form.checkValidity() Validates all controls in the form Custom submit handlers
form.reportValidity() Validates and surfaces first error Blocking invalid form submission

Required Field Validation

validate() {
  const value = this.input.value.trim();
  const required = this.hasAttribute("required");
  const isMissing = required && value.length === 0;

  this.internals.setValidity(
    { valueMissing: isMissing },
    isMissing ? "This field is required." : ""
  );

  this.internals.setFormValue(value);
}

Mirror native required behavior by setting valueMissing when the control is empty and the attribute is present.

Pattern and Length Rules

validatePassword() {
  const value = this.input.value;
  const minLength = Number(this.getAttribute("minlength")) || 8;
  const tooShort = value.length < minLength;
  const pattern = this.getAttribute("pattern");

  let patternMismatch = false;
  if (pattern) {
    patternMismatch = !new RegExp(pattern).test(value);
  }

  const flags = {
    tooShort,
    patternMismatch
  };

  const message = tooShort
    ? `Use at least ${minLength} characters.`
  : patternMismatch
    ? "Password must include letters and numbers."
    : "";

  this.internals.setValidity(flags, message, this.input);
}

Custom elements can honor the same declarative attributes as native inputs, keeping the API familiar for form authors.

Async Validation Example

async validateUsername() {
  const value = this.input.value.trim();

  if (!value) {
    this.internals.setValidity(
      { valueMissing: true },
      "Username is required."
    );
    return;
  }

  const available = await this.checkAvailability(value);

  this.internals.setValidity(
    { customError: !available },
    available ? "" : "That username is already taken."
  );
}

Server-side checks such as username availability still flow through setValidity(). Debounce async validation to avoid excessive network requests while the user types.

Form-Level Validation Flow

form.addEventListener("submit", (event) => {
  if (!form.reportValidity()) {
    event.preventDefault();
  }
});

When all form-associated custom elements report validity correctly, native form.reportValidity() works across the entire form, including custom controls.

Accessible Validation Feedback

<input
  part="input"
  aria-describedby="email-error"
  aria-invalid="true"
/>
<span id="email-error" part="error" role="alert">
  Enter a valid email address.
</span>
  • Set aria-invalid="true" when the field is invalid.
  • Link errors with aria-describedby.
  • Use role="alert" or a live region for dynamic messages.
  • Move focus to the first invalid field on submit when appropriate.
  • Do not rely on color alone to indicate errors.

Full Validated Text Field

class AppTextField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ["required", "minlength"];

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.render();
    this.input.addEventListener("input", () => this.validate());
  }

  attributeChangedCallback() {
    this.validate();
  }

  validate() {
    const value = this.input.value.trim();
    const required = this.hasAttribute("required");
    const min = Number(this.getAttribute("minlength")) || 0;

    const flags = {
      valueMissing: required && !value,
      tooShort: value.length > 0 && value.length < min
    };

    const message = flags.valueMissing
      ? "This field is required."
      : flags.tooShort
        ? `Enter at least ${min} characters.`
        : "";

    this.internals.setValidity(flags, message, this.input);
    this.internals.setFormValue(value);
    this.updateErrorUI(message, Object.keys(flags).some((k) => flags[k]));
  }

  updateErrorUI(message, invalid) {
    this.error.textContent = message;
    this.error.hidden = !invalid;
    this.input.toggleAttribute("aria-invalid", invalid);
  }
}

Validation Strategies

Strategy When to Validate Best For
On submit Form submit event Simple forms, fewer distractions
On blur Field loses focus Text inputs after user finishes typing
On input Every keystroke Password strength, live feedback
Debounced async After pause in typing Server availability checks

When Custom Validation Matters

  • You build reusable form controls for a design system.
  • Native inputs cannot express your UI or behavior.
  • You need the same field to work in multiple frameworks.
  • Validation rules must sync with form submission.
  • You combine multiple inputs into one composite control.
  • Server-side and client-side rules must surface consistently.

Form Validation Best Practices

  • Use formAssociated = true and attachInternals().
  • Call setFormValue() whenever the control value changes.
  • Report validity with meaningful, specific error messages.
  • Clear errors immediately when the value becomes valid.
  • Support declarative attributes like required and pattern.
  • Expose checkValidity() and reportValidity() behavior.
  • Pair visual errors with ARIA attributes for accessibility.

Common Validation Mistakes

  • Showing errors without calling setValidity().
  • Forgetting setFormValue() during validation updates.
  • Only validating on submit with no field-level feedback.
  • Using customError without a user-facing message.
  • Leaving aria-invalid set after the field is fixed.
  • Running unbounded async checks on every keystroke.
  • Building custom validation that bypasses the native form entirely.

Key Takeaways

  • Custom form controls should participate in native form validation.
  • ElementInternals.setValidity() is the core reporting API.
  • ValidityState flags describe why a field failed.
  • reportValidity() connects validation to user feedback.
  • Accessible error messaging is part of correct validation design.
  • Good validation keeps forms predictable across apps and frameworks.

Pro Tip

Treat validation as part of your component's public API. If form.reportValidity() works with your custom control, consumers can use the same submit flow they already trust with native inputs.