Everyone writes updateFormValues() wrong.
If you work in Web Components (like Lit, Fast, or just plain vanilla web components), under the new
hotness of attachedInternals and formAssociated, DOM methods that allow you to create new web
components that interact with forms. You can now write your own input controls, which is very cool,
and fraught with difficulty.
Every example of a custom control includes a function named something like updateFormValues().
Forms don't automatically extract their values from the input controls at submit time; each control
has to push its value to the form after it has been updated and validated. updateFormValues() is
that function: it takes the input being monitored by the component and passes it to the form, so
that when the form is submitted the values are sent wherever the action attribute says they should
go.
Unfortunately, every example of updateFormValues I have seen is wrong.
The problem is very simple: the protocol for submitting values is very old, predating fetch, or
JSON, or even XMLHttpRequest. Before any of those, the protocol was called CGI: Common
Gateway Interface. You've seen a remnant of CGI: the query string in any URL is still written in
that format: name=value&name2=value.
So what happens when the same key appears twice in that string? The answer is that you get a list of
values associated with that key. This was convenient because it allowed both multi-select and
multiple check boxes with the same input name to transmit multiple values to the back end. You would
get the response to multiple checkboxes, for example, like flavor=vanilla&flavor=strawberry.
Coders implementing the backend would have to understand that this was possible, and not just use a
hash; they would have to use some more complicated data structure to store lists.
Normally, when you send an update to a form, you just send it the key/value pair. If you must send
the form a list, you have to send it in a FormData object. The form's root FormData object is
the root of a tree of other FormData objects.
I have not yet seen an example of updateFormValues() that correctly implements this, and it's not
a very easy problem to deal with. You have to scan every child control of the form, check if they
have the same name as the component that's currently handling input, check if they form a list, and
then send an updated list to the form.
Below is my current implementation for updateFormValues() for a collection of toggles. It
correctly records the values even if it shares a name with another form of input such as a text box.
This is the correct way to do it. Everyone else is doing it wrong.
const isFormAssociated = (v: any): v is FormAssociated =>
typeof v === "object" &&
v !== null &&
"name" in v &&
"constructor" in v &&
"formAssociated" in v.constructor;
Class MyAwesomeCustomControl extends LitElement {
// ...
updateFormValues() {
if (!(this.internals.form && this.name)) {
return;
}
// Get all the controls sharing this name that belong to the current form.
const siblings = Array.from(
this.internals.form.querySelectorAll(`[name=${this.name}]`)
).filter(
el => isFormAssociated(el) && !!el.name && el.constructor?.formAssociated
);
// Get all the controls sharing this name. This is a little weird, but
// bear with me: `<input type="text">` doesn't have a "checked"
// property. So we accept things that *don't* take "checked", or things
// that do *and* are checked.
const checkedSiblings = siblings.filter(
el => isFormAssociated(el) && (!("checked" in el) || el.checked)
) as FormAssociated[];
// The `on` there is for completeness, and it will work fine but it will
// also only have one value, `on`, to submit.
const newValueSet = checkedSiblings.map(el => el.value ?? "on");
if (newValueSet.length === 0) {
this.internals.setFormValue(null);
return;
}
if (newValueSet.length === 1) {
this.internals.setFormValue(newValueSet[0]);
return;
}
const newData = new FormData();
// Yes, you *must* duplicate the name for each one, since this isn't the
// Form's FormData, it's your checkboxes' FormData that you will be
// sending as the value of your control.
newValueSet.forEach(v => newData.append(this.name, v));
this.internals.setFormValue(newData);
}
}