A form-associated custom element that only calls setFormValue submits correctly but misbehaves everywhere else: pressing a reset button leaves it unchanged, a disabled fieldset does not disable it, and going back to the page after submitting shows native inputs with their values restored while the custom one is empty.
The fix is the four lifecycle callbacks that the platform calls on form-associated elements. Form-associated custom elements with ElementInternals covers value and validity; this page covers the lifecycle, so the element is indistinguishable from a native control in every situation a form can be in. It is part of web components and form association.
Context and prerequisites
The callbacks, and when the browser calls them:
formAssociatedCallback(form)— when the element is associated with a form or disassociated (formisnull), including when it moves in the DOM or itsformattribute changes.formDisabledCallback(disabled)— when the element’s disabled state changes, including via an ancestor<fieldset disabled>.formResetCallback()— when its form is reset (reset button,form.reset()).formStateRestoreCallback(state, mode)— when the browser restores state:modeis"restore"for back/forward and session restore,"autocomplete"for autofill of a custom control.
The element must declare static formAssociated = true and attach ElementInternals. Restoration works only if the element saved state with the second argument of setFormValue(value, state).
The core pattern: a rating control with the full lifecycle
class StarRating extends HTMLElement {
static formAssociated = true;
static observedAttributes = ["value", "required"];
#internals = this.attachInternals();
#value = "";
#buttons: HTMLButtonElement[] = [];
constructor() {
super();
const root = this.attachShadow({ mode: "open", delegatesFocus: true });
root.innerHTML = `<div role="radiogroup" part="group">${[1, 2, 3, 4, 5]
.map((n) => `<button type="button" role="radio" aria-checked="false" data-v="${n}" aria-label="${n} star${n > 1 ? "s" : ""}">★</button>`)
.join("")}</div>`;
this.#buttons = [...root.querySelectorAll("button")];
root.addEventListener("click", (e) => {
const v = (e.target as HTMLElement).closest("button")?.dataset.v;
if (v) this.#set(v, true);
});
}
// The DEFAULT value lives in the attribute, like a native input's value attribute.
get defaultValue() { return this.getAttribute("value") ?? ""; }
get value() { return this.#value; }
set value(v: string) { this.#set(v, false); }
connectedCallback() { if (!this.#value) this.#set(this.defaultValue, false); }
#set(v: string, fromUser: boolean) {
this.#value = v;
// Second argument = state for restoration. Here value and state are the same;
// richer controls store more (e.g. an open panel, a partially typed search).
this.#internals.setFormValue(v || null, v);
this.#buttons.forEach((b) => b.setAttribute("aria-checked", String(b.dataset.v === v)));
this.#validate();
if (fromUser) this.dispatchEvent(new Event("input", { bubbles: true }));
}
#validate() {
if (this.hasAttribute("required") && !this.#value) {
this.#internals.setValidity({ valueMissing: true }, "Choose a rating.", this.#buttons[0]);
} else {
this.#internals.setValidity({});
}
}
formAssociatedCallback(form: HTMLFormElement | null) {
// e.g. read form-level config; here, just re-validate in the new context.
this.#validate();
}
formDisabledCallback(disabled: boolean) {
// Mirror native behaviour: not focusable, not operable, visually disabled.
this.#buttons.forEach((b) => (b.disabled = disabled));
this.toggleAttribute("aria-disabled", disabled);
}
formResetCallback() {
// Reset restores the DEFAULT (attribute), not "empty".
this.#set(this.defaultValue, false);
}
formStateRestoreCallback(state: string | File | FormData | null, _mode: "restore" | "autocomplete") {
if (typeof state === "string") this.#set(state, false);
}
attributeChangedCallback(name: string) {
if (name === "required") this.#validate();
}
}
customElements.define("star-rating", StarRating);
Step-by-step walkthrough
- Separate default value from current value. Keep the default in an attribute (
value="3") and the current value in a private field, exactly like a native input’svalueattribute and property. - Save state on every change.
setFormValue(value, state)— the state argument is what the browser hands back for restoration. Without it, back/forward shows an empty control. - Reset to the default in
formResetCallback. Then update the form value and validity; a reset that changes the visuals but notsetFormValuesubmits stale data. - Disable internal controls in
formDisabledCallback. The browser already excludes a disabled form-associated element from submission; you must stop its internals being focusable and operable, as described in skipping validation for disabled and hidden fields. - Apply saved state in
formStateRestoreCallback. Handle bothrestoreandautocompletemodes; validate after applying. - Re-validate on association. Moving the element into a different form may change what “valid” means if validation depends on the form.
Why reset means “default”, not “empty”
A common mistake is clearing the control in formResetCallback. Native inputs do not clear on reset; they return to their default value — the value attribute for text inputs, the checked attribute for checkboxes, the selected option for selects. Edit forms rely on this: a “Discard changes” button of type="reset" should put the loaded values back. If your element clears instead, it behaves differently from every native control beside it, and users lose the original value they wanted to return to. Keep the default in an attribute that server rendering or the framework sets, and reset to it.
Failure modes and edge cases
1. Restore only works with bfcache disabled
Pages served from the back/forward cache are restored whole — JavaScript state included — and no callback fires, because nothing needs restoring. formStateRestoreCallback matters for pages that are not in bfcache (for example, pages with unload handlers or Cache-Control: no-store) and for session restore. Test both.
2. Upgrade timing
If the element is defined after the browser attempted restoration, the callback runs on upgrade with the saved state. Initialise internals in the constructor so the callback has everything it needs even before connectedCallback.
3. Framework wrappers overriding reset
React and Vue do not know about native reset for custom elements. If the framework also holds the value, a native reset changes the element but not framework state. Either avoid type="reset" in framework-managed forms or listen for the form’s reset event and reset framework state too.
4. Validity anchors
The third argument to setValidity — the anchor — is where browsers point their validation bubble and where focus goes on reportValidity(). Anchor to a focusable internal control, or to nothing; anchoring to a non-focusable node breaks keyboard navigation to the error.
5. Autocomplete mode
mode: "autocomplete" is for browser autofill of custom controls, which is rare but specified. Treat it like a user change: apply the value, validate, and dispatch input so frameworks listening on the host update their state, as in handling browser autofill in controlled inputs.
Verification checklist
Frequently Asked Questions
Do I need all four callbacks?
Implement formResetCallback and formDisabledCallback for any real control; they cover everyday behaviour. formStateRestoreCallback is needed if users navigate back to forms (most apps). formAssociatedCallback is optional unless the element depends on its form.
What should the state argument contain?
Whatever you need to rebuild the control’s UI, which may be more than the submitted value — for a multi-select combobox, the selected ids plus the typed filter text. It can be a string, File or FormData.
Does Safari support these callbacks?
Form-associated custom elements, including ElementInternals and the lifecycle callbacks, are supported in current Chromium, Firefox and Safari. Feature-detect "attachInternals" in HTMLElement.prototype if you support older browsers, and fall back to a hidden native input.