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
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:
- No
setFormValue()call means no entry. UntilsetFormValue()is invoked, the element’s submission value isnull, and anullsubmission value contributes nothing — exactly as if the control were empty. The MRE above never calls it, so the entry list skips the element entirely. - No
namealso means no entry. Even withsetFormValue()wired up, the entry list assigns each value to the host element’snameattribute. A form-associated custom element without anameis 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.
Related
- Form-Associated Custom Elements — parent topic and full API surface.
- Exposing Custom Validity States to CSS — sibling deep-dive on validity and
:state(). - Lifecycle Callbacks Deep Dive — where the form reactions sit in the lifecycle.
- Core Architecture & Lifecycle Management — grandparent section overview.