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:

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:

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:

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:

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') {
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:

form.addEventListener('submit', e => {
e.preventDefault();
const data = formDataMap(form, e.submitter);
/* do great things with `data` */
});

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:

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.