Get useful input values with formDataMap()
Chris Ferdinandi's recent article for 12 Days of Web called FormData API reminded me about a helper function I wrote to quickly wire up plain HTML form controls in interactive demos of the move slider make thing happen type, without needing to reach for dat.gui or a similar library.
When your set of controls is organized as a plain HTML form, the FormData
DOM interface is a useful way to read the values. It also interacts nicely with other APIs, such as fetch()
and URLSearchParams
.
For our specific use case, it does present a couple of inconveniences:
- you read its entries via a dedicated API, with two separate methods
.get()
and.getAll()
depending on whether you're expecting one value or many; - everything except
Blob
s is cast to a string.
Let's take these minuscule concerns as an opportunity to implement a slightly-enhanced version of FormData
of our own, that produces a plain old JavaScript object, and keeps the value types closer to their underlying form controls.
Let's reimplement FormData
for some reason
The HTML specification helpfully lists the steps for constructing the entry list for a given form element. We want our implementation to be fairly robust against the markup you'd write today, but not necessarily cover the historical aspects; as such, the implementation is going to gloss over <input type='image'>
with its very specific behavior.
Finding elements that should be submitted
Form elements are grouped in overlapping categories:
- Submittable elements are
<button>
,<input>
,<select>
,<textarea>
, and any form-associated custom elements. - Listed elements include
<fieldset>
,<object>
, and<output>
, in addition to submittable elements. They can have an explicitform
attribute that associates them with a form somewhere else in the DOM tree.
The HTMLFormElement.elements
collection does the legwork of gathering the listed elements associated with the form, taking into account any explicit form
attribute on the elements. The only work that leaves for our implementation is to filter out any element types that are listed but not submittable:
function formDataMap(form, submitter) {
const excludedTags = ['FIELDSET', 'OBJECT', 'OUTPUT'];
const submittable = Array.from(form.elements)
.filter(el => !excludedTags.includes(el.tagName));
}
Out of all submittable elements, only form controls that comply with a set of rules are actually submitted. Any such element must:
- have a non-empty
name
attribute (except for custom elements associated with the form, which in some cases can go without); - not be disabled, either via its
disabled
attribute or through its position in the DOM tree; - not be nested inside a
<datalist>
element; - be checked, in the case of
radio
andcheckbox
inputs; - not be a button, except for the button that submitted the form, if applicable.
These rules are merged into the shouldSubmit(el)
function below:
function formDataMap(form, submitter) {
const excludedTags = ['FIELDSET', 'OBJECT', 'OUTPUT']
const excludedTypes = ['button', 'reset', 'image'];
function shouldSubmit(el) {
if (!el.name) return false;
if (excludedTags.includes(el.tagName)) return false;
if (excludedTypes.includes(el.type)) return false;
if (el.type === 'submit' && el !== submitter) return false;
if (el.type === 'radio' && !el.checked) return false;
if (el.type === 'checkbox' && !el.checked) return false;
if (el.disabled || el.matches(':disabled')) return false;
if (el.closest('datalist')) return false;
return true;
}
const toSubmit = Array.from(form.elements).filter(shouldSubmit);
}
Adding values based on the type of form control
With the rules out of the way, on to the fun part of getting nice values based on the type of each element. Here's the plan:
String
: most form controls work okay with a string value. For inputs of typecheckbox
,color
,email
,hidden
,password
,radio
,search
,tel
, ortext
, as well as for<textarea>
elements, the plainelement.value
suffices. Similarly,<select>
elements produce strings.Number
for inputs of typesnumber
andrange
.Date
for inputs of typedate
,datetime-local
; date-adjacent types such asmonth
,time
, andweek
are left as strings currently, but they can probably afford something more interesting.File
for inputs of typefile
.URL
for inputs of typeurl
.
Putting everything all together, here's the final function:
function formDataMap(form, submitter) {
const excludedTags = ['FIELDSET', 'OBJECT', 'OUTPUT']
const excludedTypes = ['button', 'reset', 'image'];
function shouldSubmit(el) {
if (!el.name) return false;
if (excludedTags.includes(el.tagName)) return false;
if (excludedTypes.includes(el.type)) return false;
if (el.type === 'submit' && el !== submitter) return false;
if (el.type === 'radio' && !el.checked) return false;
if (el.type === 'checkbox' && !el.checked) return false;
if (el.disabled || el.matches(':disabled')) return false;
if (el.closest('datalist')) return false;
return true;
}
const result = {};
function append(key, val) {
result[key] =
Object.hasOwn(result, key) ?
[].concat(result[key], val)
: val;
};
Array.from(form.elements).forEach(el => {
if (!shouldSubmit(el)) return;
const { name, type } = el;
if (type === 'number' || type === 'range') {
append(name, +el.value);
} else if (type === 'date' || type === 'datetime-local') {
append(name, el.valueAsDate());
} else if (type === 'file') {
append(name, el.files);
} else if (type === 'url') {
append(name, new URL(el.value));
} else if (type === 'select-one' || type === 'select-multiple') {
Array.from(el.selectedOptions).forEach(
option => append(name, option.value)
);
} else {
append(name, el.value);
}
});
return result;
};
Let's use formDataMap()
One great web platform feature with which to pair formDataMap()
is event propagation, which enables us to capture events on the ancestor form:
<form id='song-config'>
<label>
Song title:
<input type='text' name='title' />
</label>
<label>
Cowbell level:
<input name='cowbell' type='range' min='0' max='11'>
</label>
<button type='submit'>Apply configuration</button>
</form>
<script type='module'>
const form = document.getElementById("song-config");
form.addEventListener('input', e => {
const data = formDataMap(form);
/* do great things with `data` */
});
</script>
Depending on the level of responsiveness the interactive demo needs, you can choose between input
, change
and submit
events. The latter also gives you access to the form's submitter
element, which can be factored into the returned data, a feature FormData does not currently support [whatwg/xhr#262]:
form.addEventListener('submit', e => {
e.preventDefault();
const data = formDataMap(form, e.submitter);
/* do great things with `data` */
});
Update March 11, 2023: The aforementioned issue has been fixed, with submitter
added as a second, optional argument to the FormData()
constructor. Chrome 112 is the first browser shipping support for it.
Conclusion
In all fairness, [leans in and starts whispering:] instead of reimplementing everything from scratch, you could get most of the same functionality with the form's vanilla FormData
object serialized to a plain JavaScript object, followed by casting the values to numbers, dates, etc. as needed before using them for computations.
However, for that extra bit of convenience, formDataMap()
is listed in full on its separate page.
Appendix: Handling custom HTML elements
ElementInternals is a new API that lets custom HTML elements participate in forms. The article More capable form controls by Arthur Evans goes into more detail, but in a nutshell, your custom element needs to:
- make itself form-associated by declaring the static
formAssociated
property; - access form functionality with the
ElementInternals.attachInternals()
method.
class MyControl extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
// Set the element's submission value
this.internals.setFormValue('some-value');
}
}
customElements.define('my-control', MyControl);
With the setFormValue()
method, custom elements can specify their submission value which the FormData
API can access. The submission value can be a string, a Blob
, or a FormData
object. The latter is used when the custom element wants to relay multiple values, and it's the only case when a custom element doesn't need a name
attribute for it to be included in the form's submission data.
Unfortunately for us, the value set with the setFormValue()
method is not accessible through any standard interface, so it's up to each custom element to decide how (and if) to expose an equivalent.
To remain generic, formDataMap()
can only fall back to the standard append(name, el.value)
approach.