Form Integration
Custom elements can render inside a <form>, but that does not automatically make them form controls. A normal custom element is ignored by FormData, native constraint validation, fieldset disabled state, and form reset behavior unless it opts into the browser's Form-Associated Custom Elements API.
@beforesemicolon/web-component supports that native API directly. It exposes the element's ElementInternals instance as this.internals, while you use standard browser callbacks such as formAssociatedCallback(), formDisabledCallback(), formResetCallback(), and formStateRestoreCallback().
The Default Problem
Start with a component that wraps a native input:
1import { WebComponent, html } from '@beforesemicolon/web-component'2 3class TextField extends WebComponent {4 static observedAttributes = ['value', 'placeholder', 'name']5 6 value = ''7 placeholder = ''8 name = ''9 10 handleInput = (event) => {11 this.value = event.target.value12 this.dispatch('change', { value: this.value })13 }14 15 render() {16 return html`17 <input18 type="text"19 value="${this.props.value}"20 placeholder="${this.props.placeholder}"21 oninput="${this.handleInput}"22 />23 `24 }25}26 27customElements.define('text-field', TextField)You can place it in a form:
1<form id="profile-form">2 <text-field name="firstName" placeholder="First name"></text-field>3 <button type="submit">Save</button>4</form>But submitting the form will not include firstName:
1const form = document.querySelector('#profile-form')2 3form.addEventListener('submit', (event) => {4 event.preventDefault()5 6 const data = new FormData(event.currentTarget)7 8 console.log(Object.fromEntries(data)) // {}9})The browser sees the custom element as an element, not as a successful form control with a submitted value.
Enable Form Association
Add static formAssociated = true so the browser treats the custom element as form-associated:
1class TextField extends WebComponent {2 static formAssociated = true3 static observedAttributes = ['value', 'placeholder', 'name']4}This is native custom element behavior, not a WebComponent-specific abstraction. It allows the element to be associated with a parent form and receive native form callbacks.
Form association alone does not submit a value. It only lets the custom element participate in the form system. You still need this.internals.setFormValue().
ElementInternals
this.internals exposes the native ElementInternals object created with attachInternals().
1get internals(): ElementInternalsUse it to communicate with the parent form:
setFormValue(value): controls whatFormDatareceives for the element'sname.setValidity(flags, message, anchor): controls native validity state.reportValidity(): asks the browser to show validation UI.form: returns the associated form.labels: returns labels associated with the custom element.
Register the Submitted Value
Call setFormValue() whenever the component value changes. The submitted field name comes from the custom element's own name attribute.
1handleInput = (event) => {2 const value = event.target.value3 4 this.value = value5 this.internals.setFormValue(value)6 this.dispatch('change', { value })7}Now the same form produces useful data:
1console.log(Object.fromEntries(new FormData(form)))2// { firstName: "Ada" }You can also register an initial value when the browser associates the element with a form:
1formAssociatedCallback() {2 this.internals.setFormValue(this.props.value())3}Validation
A form-associated custom element can use native validation instead of inventing a parallel error system. A common pattern is to delegate validity to the internal native control, then report that validity through ElementInternals.
1validate(report = false) {2 const input = this.refs.input?.[0]3 4 if (!input) return5 6 const validity = input.validity7 const message = validity.valid ? '' : this.props.error()8 9 this.internals.setValidity(10 validity,11 message,12 validity.valid ? undefined : input13 )14 15 if (report) {16 this.internals.reportValidity()17 }18}setValidity() accepts the same validity flags exposed by native form controls. Passing an empty or valid ValidityState clears the error. Passing an anchor element lets the browser position native validation UI near the relevant internal control.
Native Form Callbacks
Form-associated custom elements use native callback names. WebComponent does not wrap these because they are part of the browser platform.
formAssociatedCallback(form)
Called when the browser associates or disassociates the element with a form. Use this to register the initial submitted value and validity.
1formAssociatedCallback() {2 this.syncValue(this.props.value(), false)3}formDisabledCallback(disabled)
Called when the element becomes disabled because its own disabled attribute changed or because an ancestor <fieldset> changed disabled state.
1formDisabledCallback(disabled) {2 this.disabled = disabled3}If your template passes this.props.disabled into an internal input, assigning this.disabled updates the prop and keeps the rendered control in sync.
formResetCallback()
Called when the parent form resets. Use it to restore the component's default value, clear validation state, and update the native form value.
1formResetCallback() {2 this.syncValue('', false)3}formStateRestoreCallback(state, mode)
Called when the browser restores form state, for example after navigation or autocomplete. Use it to restore the component's visible value and submitted value.
1formStateRestoreCallback(state, mode) {2 if (mode === 'restore' || mode === 'autocomplete') {3 this.syncValue(String(state ?? ''), false)4 }5}Complete Text Field
This example keeps the custom element API small while integrating with native form submission, validation, reset, fieldset disabled state, and restore behavior.
1import { WebComponent, html } from '@beforesemicolon/web-component'2 3class TextField extends WebComponent {4 static formAssociated = true5 static observedAttributes = [6 'value',7 'placeholder',8 'name',9 'pattern',10 'disabled',11 'required',12 'error',13 ]14 15 value = ''16 placeholder = ''17 name = ''18 pattern = ''19 disabled = false20 required = false21 error = 'Invalid field value.'22 23 formAssociatedCallback() {24 this.syncValue(this.props.value(), false)25 }26 27 formDisabledCallback(disabled) {28 this.disabled = disabled29 }30 31 formResetCallback() {32 this.syncValue('', false)33 }34 35 formStateRestoreCallback(state, mode) {36 if (mode === 'restore' || mode === 'autocomplete') {37 this.syncValue(String(state ?? ''), false)38 }39 }40 41 syncValue(value, report = true) {42 this.value = value43 this.internals.setFormValue(value)44 45 const input = this.refs.input?.[0]46 47 if (input) {48 const validity = input.validity49 50 this.internals.setValidity(51 validity,52 validity.valid ? '' : this.props.error(),53 validity.valid ? undefined : input54 )55 56 if (report) {57 this.internals.reportValidity()58 }59 }60 61 this.dispatch('change', { value })62 }63 64 handleInput = (event) => {65 this.syncValue(event.target.value)66 }67 68 render() {69 const { error, ...inputAttrs } = this.props70 71 return html`72 <input73 ${inputAttrs}74 ref="input"75 part="input"76 type="text"77 oninput="${this.handleInput}"78 />79 `80 }81}82 83customElements.define('text-field', TextField)Use it like a normal form field:
1<form id="profile-form">2 <text-field3 name="firstName"4 placeholder="First name"5 pattern="[A-Za-z]+"6 required7 error="First name can only contain letters."8 ></text-field>9 10 <button type="reset">Reset</button>11 <button type="submit">Save</button>12</form>Practical Rules
- Add
static formAssociated = trueonly to components that should behave as native form controls. - Always call
this.internals.setFormValue()when the submitted value changes. - Keep the submitted value and the visible internal control value synchronized.
- Use
this.internals.setValidity()when the custom element should participate in native constraint validation. - Use
formDisabledCallback()to respond to ancestor<fieldset disabled>changes. - Use
formResetCallback()to restore defaults when the parent form resets. - Use
formStateRestoreCallback()for browser restore and autocomplete flows. - Keep component events like
changeuseful for app code, but do not rely on events for native form submission.