Custom Element Form Reset and Restore
A custom form control can submit a value and report validity and still misbehave in the moments users notice most: the Reset button clears every native input but leaves the star rating selected; a <fieldset disabled> greys out the text fields but the colour picker stays clickable and still blocks submission; pressing Back after submitting restores every native field but the tag input comes back empty; the browser’s autofill fills the address but skips the custom country picker. Each of these is a lifecycle callback that form-associated elements must implement: formResetCallback, formDisabledCallback, formStateRestoreCallback and formAssociatedCallback. This recipe implements all four on a colour-swatch picker, shows how they interact with validity, and keeps the element aligned with the Constraint Validation API so a reset or restore always leaves validity correct.
When These Callbacks Matter
Implement them for every form-associated element; each covers a path that users hit without thinking of it as special:
- Reset — explicit Reset buttons are rare, but frameworks and “Clear form” actions call
form.reset(), and so do tests. - Disabled — conditional sections disable whole fieldsets, the pattern used in validating nested object fields to exclude optional groups from validation.
- Restore — the back-forward cache and session history restore form state after navigation; browsers also use restore for some autofill flows.
- Association — elements moved between forms, or inserted with a
formattribute, need to know their owner changed.
The element must already be form-associated with ElementInternals, as described in form-associated custom elements.
Minimal Working Lifecycle Implementation
const SWATCHES = ["#4f46e5", "#0891b2", "#059669", "#d97706", "#dc2626"] as const;
export class SwatchPicker extends HTMLElement {
static formAssociated = true;
static observedAttributes = ["value", "required"];
#internals = this.attachInternals();
#value = "";
#touched = false;
#buttons: HTMLButtonElement[] = [];
connectedCallback(): void {
this.#internals.role = "radiogroup";
this.replaceChildren(...SWATCHES.map((hex) => {
const b = document.createElement("button");
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-label", hex);
b.style.background = hex;
b.addEventListener("click", () => { this.#touched = true; this.#set(hex, "user"); });
return b;
}));
this.#buttons = [...this.querySelectorAll("button")];
this.#set(this.getAttribute("value") ?? "", "init");
}
/** The single place that changes value, form value, UI and validity together. */
#set(value: string, source: "init" | "user" | "reset" | "restore"): void {
this.#value = SWATCHES.includes(value as (typeof SWATCHES)[number]) ? value : "";
this.#buttons.forEach((b) => {
const on = b.getAttribute("aria-label") === this.#value;
b.setAttribute("aria-checked", String(on));
b.tabIndex = on || (!this.#value && b === this.#buttons[0]) ? 0 : -1;
});
// Second argument is the *state* the browser saves for restoration.
this.#internals.setFormValue(this.#value || null, this.#value);
this.#validate();
if (source === "user") this.dispatchEvent(new Event("change", { bubbles: true }));
}
#validate(): void {
if (this.hasAttribute("required") && !this.#value) {
this.#internals.setValidity({ valueMissing: true }, "Choose a colour.", this.#buttons[0]);
} else {
this.#internals.setValidity({});
}
this.#internals.ariaInvalid = this.#touched && !this.#internals.validity.valid ? "true" : "false";
}
// ── Lifecycle callbacks ────────────────────────────────────────────────
formResetCallback(): void {
this.#touched = false; // a reset form is pristine again
this.#set(this.getAttribute("value") ?? "", "reset"); // back to the default, not to empty
}
formDisabledCallback(disabled: boolean): void {
this.#buttons.forEach((b) => (b.disabled = disabled));
this.#internals.ariaDisabled = String(disabled);
// No validity change needed: disabled elements have willValidate === false and are skipped.
}
formStateRestoreCallback(state: string | File | FormData | null, mode: "restore" | "autocomplete"): void {
if (typeof state === "string") this.#set(state, "restore");
if (mode === "autocomplete") this.#touched = true; // autofilled values are shown like user input
}
formAssociatedCallback(form: HTMLFormElement | null): void {
// Owner changed (moved, or form attribute set). Re-validate against the new context.
if (form) this.#validate();
}
attributeChangedCallback(): void {
if (this.#buttons.length) this.#validate();
}
get value() { return this.#value; }
get validity() { return this.#internals.validity; }
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { this.#touched = true; this.#validate(); return this.#internals.reportValidity(); }
}
customElements.define("swatch-picker", SwatchPicker);
Routing reset and restore through the same #set method as user input is the whole trick. Value, form value, restore state, UI and validity are always updated together, so a reset can never leave an invalid element looking valid, and a restored element can never submit a different value from the one it shows.
Lifecycle Callback Reference
| Callback | Arguments | Fires when | What to do |
|---|---|---|---|
formResetCallback |
none | form.reset() / reset button |
Restore the default (the value attribute), clear touched, revalidate |
formDisabledCallback |
disabled: boolean |
Element or ancestor fieldset disabled/enabled | Disable interaction, reflect ariaDisabled |
formStateRestoreCallback |
state, mode |
History restore or autofill | Rebuild UI from state; mark autofilled as touched if you show errors |
formAssociatedCallback |
form or null |
Owner form changes | Re-read form-level context and revalidate |
setFormValue(value, state) |
value, restore state | You call it | Keep state sufficient to rebuild the UI |
The second argument of setFormValue — the state — is easy to skip and important to get right. The value is what is submitted; the state is what the browser saves to restore your UI. For a simple component they are the same string; for a complex one (a date range with a selected preset, a tag input with ordering) the state can hold more than the submitted value.
Verification Steps
import { test, expect } from "@playwright/test";
test("swatch-picker resets and restores like native inputs", async ({ page }) => {
await page.goto("/theme");
const picker = page.locator("swatch-picker");
await page.getByRole("radio", { name: "#059669" }).click();
await page.getByRole("button", { name: "Reset" }).click();
expect(await picker.evaluate((el: any) => el.value)).toBe("");
expect(await picker.evaluate((el: any) => el.validity.valueMissing)).toBe(true);
await page.getByRole("radio", { name: "#d97706" }).click();
await page.getByRole("button", { name: "Save" }).click();
await page.goBack();
await expect(page.getByRole("radio", { name: "#d97706" })).toHaveAttribute("aria-checked", "true");
});
Edge Cases and Failure Modes
Resetting to empty instead of the default. Native inputs reset to their value attribute, not to an empty string. A custom element that resets to "" breaks forms that pre-populate saved values. Read the attribute in formResetCallback.
Restore state that cannot rebuild the UI. If state is only the submitted value but the UI needs more (which preset was chosen, which tab was open), restoration shows the wrong view. Serialise enough into state to rebuild everything visible.
Showing errors after reset. Keeping touched set after reset makes a pristine form display errors immediately. Clear it in formResetCallback, as native :user-invalid does.
Disabled but still required. Some implementations keep setting valueMissing while disabled and then wonder why validity looks wrong in DevTools. It is harmless — willValidate is false, so the form ignores it — but for clarity you can skip validation while disabled.
Testing Lifecycle Callbacks Without a Browser Session
Back-forward restoration is awkward to test end to end, because it depends on the browser’s history cache. Two complementary approaches keep the callbacks covered. Call the callbacks directly in a component test — picker.formStateRestoreCallback("#059669", "restore") — and assert the UI, value and validity, which exercises your logic without relying on cache behaviour. Then keep one Playwright test that performs a real navigation away and back, as shown above, to catch integration problems such as a restore state that does not match what #set expects. Reset and disabled callbacks are easy to trigger for real (form.reset(), toggling fieldset.disabled), so test those through the form rather than by calling the callbacks. Include each path in the same parameterised suite you run against native inputs, so any divergence between the custom element and an <input> shows up as a failing case rather than a production bug.
Autofill and the autocomplete Restore Mode
When formStateRestoreCallback is called with mode === "autocomplete", the browser is filling your control as part of autofill rather than history restoration. Support is still limited — most browsers autofill only native controls today — but when it happens the right behaviour mirrors native inputs: accept the value, update the UI, recompute validity, and treat the field as interacted with so any error is shown (an autofilled value that fails validation should be visible, not silently wrong). Pair this with a sensible autocomplete attribute forwarded to any inner native input, which is how most design-system controls get autofill today, as covered in autocomplete attribute and validation.
Frequently Asked Questions
How do I make a custom element respond to form.reset()?
Implement formResetCallback in the form-associated element. Restore the default value from the value attribute, clear any touched or error-display state, and call setFormValue and setValidity again.
How does a custom element know it is inside a disabled fieldset?
The browser calls formDisabledCallback(true) when the element or an ancestor fieldset becomes disabled, and false when re-enabled. Disable interaction there; the form automatically skips validation for disabled elements.
Why does my custom element lose its value when I press Back?
The browser restores state only if you save it. Pass a restore state as the second argument to setFormValue and implement formStateRestoreCallback to rebuild the UI from it.
Does autofill work with custom form controls?
Support is limited. Browsers may call formStateRestoreCallback with mode "autocomplete"; most autofill today reaches custom controls through an inner native input with a forwarded autocomplete token.
Related Guides
- Form-Associated Custom Elements — the ElementInternals foundation.
- ElementInternals setValidity for Custom Inputs — validity that reset and restore recompute.
- Validating Inputs Inside Shadow DOM — wrapping native inputs.
- Tracking Dirty, Touched and Pristine State — the touched state that reset clears.