Participating in Native Form Submission

A custom input that looks finished — it renders, it has a name, it updates an internal value — can still contribute nothing to a form submission. The value is right there in the component, yet the submitted FormData is empty for that field. This deep-dive isolates that exact failure, explains the spec algorithm that causes it, and gives the complete fix, including the multi-value FormData case and state restoration.

This page belongs to the Form-Associated Custom Elements topic, within Core Architecture & Lifecycle Management.

Minimal Reproducible Example: The Silently Dropped Value

The control below is form-associated and has a name. A user types into it, the internal #value updates, and the form is submitted. Logging the FormData shows no quantity entry at all.

class QuantityInput extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  #value = '';

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML =
      `<input type="number" part="field" />`;
    this.shadowRoot.querySelector('input')
      .addEventListener('input', (e) => { this.#value = e.target.value; });
    // ❌ The internal #value is updated, but the form is never told.
  }
}
customElements.define('quantity-input', QuantityInput);
<form id="cart">
  <quantity-input name="quantity"></quantity-input>
  <button>Buy</button>
</form>
<script>
  document.getElementById('cart').addEventListener('submit', (e) => {
    e.preventDefault();
    console.log([...new FormData(e.target)]); // → []  (no "quantity" entry!)
  });
</script>

The component holds the typed value in #value, but FormData is empty. Nothing is broken in the listener; the value never enters the form’s submission set in the first place.

Root-Cause Analysis

Why an internal value never reaches FormData Comparison of the broken path where the value stays internal and the fixed path where setFormValue feeds the construct-the-entry-list algorithm. Broken input event this.#value = v private field only form never notified FormData empty — no entry Fixed value setter on every change setFormValue(v) ElementInternals entry list FormData name=value present

The WHATWG HTML Standard collects submission values through the construct the entry list algorithm. For a form-associated custom element, that algorithm reads exactly one thing: the value most recently supplied to ElementInternals.setFormValue(). It does not inspect your shadow DOM, your private fields, or any value property you happen to expose.

Two consequences follow:

  1. No setFormValue() call means no entry. Until setFormValue() is invoked, the element’s submission value is null, and a null submission value contributes nothing — exactly as if the control were empty. The MRE above never calls it, so the entry list skips the element entirely.
  2. No name also means no entry. Even with setFormValue() wired up, the entry list assigns each value to the host element’s name attribute. A form-associated custom element without a name is collected as “no entry,” identical to a nameless native <input>.

So the rule is precise: an entry appears in FormData only when the element is form-associated, has a non-empty name, and has had a non-null value pushed through setFormValue(). The MRE satisfies the first two and fails the third.

A second, easily-confused subtlety: the entry list is constructed lazily, at the moment of submission or new FormData(form) construction — not when setFormValue() is called. setFormValue() merely stores the value on ElementInternals; the browser reads that stored value during the collection algorithm. This is why a control that only ever sets its value inside an input listener has nothing to contribute if the form is submitted before any input event fires. The stored value is still its construction-time default of null, and null is collected as “no entry.” Seeding the value during connectedCallback is what closes that window.

Production-Safe Fix

The corrected control calls setFormValue() on every value change, reflects through a value property so frameworks and tests can drive it, and implements formResetCallback and formStateRestoreCallback so the form’s reset button and history navigation behave correctly.

class QuantityInput extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['value'];

  #internals;
  #input;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
    shadow.innerHTML = `<input type="number" min="0" part="field" />`;
    this.#input = shadow.querySelector('input');
    this.#input.addEventListener('input', () => {
      this.value = this.#input.value; // route through the setter
    });
  }

  connectedCallback() {
    // Seed the form value from the initial attribute so a submit
    // immediately after load still carries the default.
    this.value = this.getAttribute('value') ?? '';
  }

  get value() { return this.#input.value; }
  set value(v) {
    const next = v ?? '';
    this.#input.value = next;
    // ✅ Tell the form. Empty string → null so no entry is submitted.
    this.#internals.setFormValue(next === '' ? null : next);
  }

  // Reset button / form.reset()
  formResetCallback() {
    this.value = this.getAttribute('value') ?? '';
  }

  // Back/forward navigation and browser autofill restore.
  formStateRestoreCallback(state /*, mode */) {
    this.value = state ?? '';
  }
}
customElements.define('quantity-input', QuantityInput);

For a control that submits multiple entries under one name — a chip/tag field, a multi-select — pass a FormData to setFormValue(). Each appended pair becomes its own entry in the submitted body, and the optional second argument carries a structured restoration snapshot distinct from the wire format:

class TagField extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  #tags = [];

  get name() { return this.getAttribute('name') ?? ''; }

  #commit() {
    const data = new FormData();
    for (const tag of this.#tags) data.append(this.name, tag); // multi-entry
    // First arg = submitted value(s); second arg = restoration state.
    this.#internals.setFormValue(
      this.#tags.length ? data : null,
      JSON.stringify(this.#tags)
    );
  }

  addTag(tag) { this.#tags.push(tag); this.#commit(); }

  formResetCallback() { this.#tags = []; this.#commit(); }
  formStateRestoreCallback(state) {
    this.#tags = state ? JSON.parse(state) : [];
    this.#commit();
  }
}
customElements.define('tag-field', TagField);

Verification

Read the submitted FormData and confirm the entries appear. For the single-value control:

document.getElementById('cart').addEventListener('submit', (e) => {
  e.preventDefault();
  const data = new FormData(e.target);
  console.log(data.get('quantity')); // → "3"  (was null before the fix)
});

For the multi-entry TagField, getAll returns every appended value:

const data = new FormData(formEl);
console.log(data.getAll('topics')); // → ["css", "a11y", "perf"]

You can also verify without a round-trip by constructing new FormData(formEl) directly in DevTools — it runs the same construct the entry list algorithm the submit path uses, so a missing entry here means a missing setFormValue() call, not a submission bug.

When to Use / When to Avoid

Situation Approach
Single scalar value (text, number, choice) setFormValue(string | null); null for empty.
One control, many submitted entries setFormValue(formData) with repeated append(name, v).
Wire format differs from restorable state setFormValue(submitValue, stateSnapshot); read it in formStateRestoreCallback.
You only need a value in JS, never in a <form> Skip form association; a plain property is enough.
Must support pre-Safari-16.4 / pre-Firefox-98 Add the ElementInternals polyfill or mirror a hidden native <input>.
Framework already owns submission (e.g. fetch on click) Form association is optional, but still set the value so native validation works.

Debugging Pitfall: Calling setFormValue() only inside an event listener means a submit that happens before any interaction sends nothing. Always seed the value once in connectedCallback (and again in formResetCallback) so the default is present from the first paint.