Part 1 of 5
Production Web Component Architecture
The Input That Never Got Submitted
A Custom Form Field the Browser Submits

We built a text field as a custom element, which is an HTML tag we define ourselves, with a JavaScript class behind it. It looks right. The user types a nickname into it, clicks Send, and the form submits.
The server gets the other fields. It does not get the nickname.
Nothing threw an error. The field is on the screen, with the typed text inside it, and the form has no idea it exists.
Here is why, and the fix. At the end, our <mx-field> submits with its form like any native input:
team=web&nick=ada
It will also clear itself when the form resets, switch off when its <fieldset> is disabled, and stop the submit with a message when it is required and empty.
All of it comes from one static property and one object the browser hands us, called ElementInternals.
Why the nickname went missing#
A <form> does not look at everything inside it. It keeps a list of its form controls, which are the elements the browser already knows are controls: <input>, <select>, <textarea>, <button> and a few more. When the form submits, it walks that list and collects each control's name and value.
A custom element is not on that list. Neither is the <input> inside its shadow root, because the form does not look through a shadow boundary.
Take the failing field. The form has one native input and one custom one, and both are filled in.
---html
<form>
<input name="team" value="web">
<plain-field name="nick"></plain-field>
<button>Send</button>
</form>
<pre id="out">Press Send.</pre>
---css
:root { color-scheme: light dark; }
---js
class PlainField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" }).innerHTML = '<input value="ada">';
}
}
customElements.define("plain-field", PlainField);
const form = document.querySelector("form");
form.addEventListener("submit", (event) => {
event.preventDefault();
document.querySelector("#out").textContent =
new URLSearchParams(new FormData(form)).toString();
});
Press Send. You should see:
team=web
The nick field shows ada on the screen and contributes nothing to the form.
new FormData(form) is the same collection the browser sends, so we can read it in script without a server. URLSearchParams turns it into the familiar name=value&name=value text.
One property makes it a form control#
A custom element can tell the browser "treat me as a form control". It does that with a static property, and it gets a helper object from attachInternals().
class MxField extends HTMLElement {
static formAssociated = true;
#internals;
#input;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: "open" }).innerHTML = '<input part="input">';
this.#input = this.shadowRoot.querySelector("input");
this.#input.addEventListener("input", () => {
this.#internals.setFormValue(this.#input.value);
});
}
}
Three lines matter here:
static formAssociated = trueputs the element on the form's list of controls.this.attachInternals()returns anElementInternalsobject. It is the element's private line to the browser, and only the class that called it can use it.setFormValue(value)tells the form what to submit under this element'sname.
The form asks the element for a value. The element tells
internalswhat to answer.
Notice what the field does not do: it never writes into the form. It keeps its answer up to date, and the form reads it at submit time.
A # in front of a name makes a field private to the class. We use it so that nobody outside can reach #internals by accident.
Reset and disabled need a little help#
A native input clears itself when its form is reset, and grays out when a surrounding <fieldset disabled> says so. Our element can do both, but we have to say what "clear" means. The browser calls two methods on the element when these things happen:
| The form does this | The element gets this call |
|---|---|
form.reset() or a reset button | formResetCallback() |
The element becomes disabled or enabled, directly or through a <fieldset> | formDisabledCallback(disabled) |
There are two more, formAssociatedCallback(form) for when the element joins or leaves a form, and formStateRestoreCallback() for browser autofill and back-navigation. We will not use them here.
This is what we add to the class:
formResetCallback() {
this.#input.value = this.getAttribute("value") ?? "";
this.#sync();
}
formDisabledCallback(disabled) {
this.#input.disabled = disabled;
}
#sync is a small method that pushes the current text into the form. We will write it in a moment, and it will also hold the validation.
The browser already keeps a disabled control out of the submission, so a disabled <mx-field> is left out of FormData without our help. The callback is only for the inside: it disables the real <input> in the shadow root so that the user cannot type into a field the form has switched off.
Validation: a message and a stop#
A required native input refuses to submit while it is empty. Our element can do the same with setValidity, which takes three arguments:
internals.setValidity({ valueMissing: true }, "Please fill in this field.", anchor);
- A set of flags, using the names the native
ValidityStatealready has:valueMissing,tooShort,typeMismatch,customErrorand so on. Anytrueflag marks the element invalid. - The message the browser shows.
- An anchor, which is the element inside the shadow root that the browser should point at and focus.
Passing an empty object, setValidity({}), marks it valid again.
The #sync method does the work. It passes the current text to setFormValue, then sets the validity: invalid with the message when the field is required and empty, valid otherwise. It is in the full class further down.
We call it from the input listener, from connectedCallback so that the starting state is right, and from formResetCallback.
One catch. validity and validationMessage live on internals, not on the element, so document.querySelector("mx-field").validity is undefined. If outside code should read them, we forward them with two getters. The form does not need them. It reads the state through the browser.
Now the complete element in a live form. Try it in this order: press Send while the field is empty, type something, press Send again, then press Reset.
---html
<form>
<fieldset>
<input name="team" value="web">
<mx-field name="nick" required></mx-field>
</fieldset>
<button>Send</button>
<button type="reset">Reset</button>
</form>
<pre id="out">Nothing sent yet.</pre>
---css
:root { color-scheme: light dark; }
mx-field:invalid { outline: 2px solid crimson; outline-offset: 2px; }
---js
class MxField extends HTMLElement {
static formAssociated = true;
#internals;
#input;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: "open" }).innerHTML = '<input part="input">';
this.#input = this.shadowRoot.querySelector("input");
this.#input.addEventListener("input", () => this.#sync());
}
connectedCallback() {
this.#input.value = this.getAttribute("value") ?? "";
this.#sync();
}
get value() { return this.#input.value; }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }
#sync() {
const value = this.#input.value;
this.#internals.setFormValue(value);
if (this.hasAttribute("required") && value === "") {
this.#internals.setValidity(
{ valueMissing: true },
"Please fill in this field.",
this.#input
);
} else {
this.#internals.setValidity({});
}
}
formResetCallback() {
this.#input.value = this.getAttribute("value") ?? "";
this.#sync();
}
formDisabledCallback(disabled) {
this.#input.disabled = disabled;
}
}
customElements.define("mx-field", MxField);
const form = document.querySelector("form");
const out = document.querySelector("#out");
form.addEventListener("submit", (event) => {
event.preventDefault();
out.textContent = new URLSearchParams(new FormData(form)).toString();
});
form.addEventListener("reset", () => { out.textContent = "Reset."; });
With the field empty, Send does not print anything. The browser shows its validation bubble and focuses the real <input> inside the shadow root, because that is the anchor we gave it. After you type ada, Send prints:
team=web&nick=ada
Reset clears the field and the output says Reset.
The red outline comes from the page's own CSS. :invalid matches our element while its setValidity flags are set, exactly as it matches a native input.
We ran this in Chrome 156 on Windows. With the field empty, form.requestSubmit() fired no submit event and form.reportValidity() returned false. With the <fieldset> disabled, FormData shrank to team=web and formDisabledCallback(true) fired. After form.reset(), the field's value was an empty string.
Challenge: the field that is never wrong#
Coding challenge: Make required block the submit
The starter below is mx-field with its validation taken out. The field is always valid, so Send always works.
Fill in the TODO so that an empty required field is invalid, with the message Please fill in this field.
Expected result: with the field empty, the Check button prints valid: false and Send prints nothing. After you type ada, Check prints valid: true and Send prints team=web&nick=ada.
---html
<form>
<input name="team" value="web">
<mx-field name="nick" required></mx-field>
<button>Send</button>
<button type="button" id="check">Check</button>
</form>
<pre id="out">Press Check or Send.</pre>
---css
:root { color-scheme: light dark; }
---js
class MxField extends HTMLElement {
static formAssociated = true;
#internals;
#input;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: "open" }).innerHTML = "<input>";
this.#input = this.shadowRoot.querySelector("input");
this.#input.addEventListener("input", () => this.#sync());
}
connectedCallback() { this.#sync(); }
#sync() {
this.#internals.setFormValue(this.#input.value);
// TODO: when `required` is set and the input is empty, call
// this.#internals.setValidity({ valueMissing: true }, "...", this.#input)
this.#internals.setValidity({});
}
}
customElements.define("mx-field", MxField);
const form = document.querySelector("form");
const out = document.querySelector("#out");
form.addEventListener("submit", (event) => {
event.preventDefault();
out.textContent = new URLSearchParams(new FormData(form)).toString();
});
document.querySelector("#check").addEventListener("click", () => {
out.textContent = "valid: " + form.checkValidity();
});
What is supported, and what to do without it#
As of October 2026, MDN marks ElementInternals as Baseline widely available, and its compatibility data lists these first versions:
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
attachInternals() | 77 | 93 | 16.4 |
setFormValue(), setValidity(), validity | 77 | 98 | 16.4 |
So every current version of the three major engines has it. Safari 16.4 shipped in March 2023, which is also the date MDN gives for Baseline.
In a version older than the ones above, the usual fallback was a hidden <input name="nick"> in the light DOM, kept in sync with the shadow input. If you must support those, check for the feature first with "attachInternals" in HTMLElement.prototype, and use the hidden input only when it is false. Most projects today can skip that.
The sources for the table are MDN's ElementInternals page and its browser-compat-data.
What we learned#
- A form collects its own list of controls. A custom element joins it with
static formAssociated = true. attachInternals()returns the element's privateElementInternals.setFormValue()sets what the form submits.formResetCallback()andformDisabledCallback()let the element follow its form.setValidity(flags, message, anchor)makes the element invalid, andsetValidity({})makes it valid again.validitylives oninternals, so forward it with a getter if outside code needs it.
Our field now behaves like a form control. But when it is invalid, or focused, or full, the only way to tell the page is a class name or an attribute that anyone can overwrite. The next lesson gives the element a proper way to say how it feels.
Comments require JavaScript.