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.

What setFormValue accepts and how each shape submits A string submits one entry under the element name, a File submits as a file part, FormData submits several named entries, and null omits the control entirely. setFormValue decides exactly what the form carries a string one entry under the element's name attribute a File a file part, as a native file input would submit FormData several named entries — for a control with compound state null the control submits nothing at all — the same as never calling it

Frequently Asked Questions

Why is my control missing from the submitted data?

Either setFormValue was never called, or the element has no name attribute. Form association makes the element eligible to submit; the name and the value are both still the component’s responsibility.

Does the value need to be re-set on every change?

Yes. The form reads whatever was last passed to setFormValue, so every state change that alters the submitted value must call it again — including changes that clear the value.

How does a component submit more than one entry?

By passing a FormData object, which contributes several named entries at once. That is the supported route for a compound control such as a date-range or an address block.

What happens on form reset?

formResetCallback fires and the component must restore its own default and call setFormValue again. Nothing is restored automatically, because the browser has no idea what the component’s default means.

Three conditions that must all hold for a value to be submitted The element must be form-associated, carry a name attribute, and have called setFormValue; missing any one of them omits it silently. All three, or nothing is submitted — and no error is raised formAssociated static flag on the class a name attribute set by the consumer, in markup setFormValue called on every value change Why the failure is hard to spot The form submits successfully; the field is simply absent from the payload, which looks like a server bug.

Naming, disabled state, and the parts a form owns

Three details decide whether a form-associated component behaves like a native control in the situations nobody demos.

The name attribute belongs to the consumer. A component must not invent one, must not default one, and must read it from the host rather than from an internal element — the form reads the host’s name, and an internal input’s name is invisible to submission. A component that renders <input name="value"> inside its shadow tree and expects that to submit will find the field missing every time.

Disabled state arrives from above. A <fieldset disabled> ancestor disables every form-associated descendant, and the component learns about it through formDisabledCallback rather than through an attribute. Propagating that inward — disabling the internal control, updating the accessible state, refusing interaction — is the component’s job, and skipping it produces a control that looks disabled and still responds.

Reset and restore are distinct. formResetCallback fires when the form is reset and should restore the component’s default value, which usually means re-reading its value attribute rather than clearing to empty. formStateRestoreCallback fires when the browser restores session state after navigation and receives the value it previously captured, which may differ from the default. Treating them as the same callback loses whatever the reader had entered before navigating away.

formResetCallback() {
  this.#setValue(this.getAttribute('value') ?? '');   // the authored default
}

formStateRestoreCallback(state) {
  this.#setValue(state ?? '');                        // what the reader had entered
}

formDisabledCallback(disabled) {
  this.#input.disabled = disabled;
  this.#internals.ariaDisabled = String(disabled);
}

Together those three callbacks are the difference between a control that works in a demo form and one that survives a real one, where fieldsets nest, resets happen, and readers navigate away mid-entry.

Each of the three is one short method, and omitting any one produces a control that appears to work.