File uploads, custom error messages and progressive enhancement all depend on these details.
When a form submits, the browser builds a list of entries from successful controls (named, enabled, checked if a checkbox or radio) and encodes them according to enctype:
application/x-www-form-urlencoded(default):name=Ada+L&tags=a&tags=b, spaces as+, other bytes percent-encoded. Withmethod="get"this becomes the query string.multipart/form-data: each field in its own MIME part. Required for<input type="file">; with urlencoded only the file name is sent.text/plain: for debugging only; ambiguous to parse.
A submit button's name=value is included only for the button that was clicked, and buttons can override the form with formaction, formmethod and formnovalidate. Disabled controls are never sent; use readonly if the value must be submitted.
The constraint validation API exposes the browser's validation to scripts. Each control has validity (flags such as valueMissing, patternMismatch, tooShort, customError), validationMessage, checkValidity() and reportValidity(). setCustomValidity("message") marks a control invalid with your text; set it back to "" or the field stays invalid forever. The novalidate attribute on the form disables native bubbles while keeping the API, which is how custom error UIs are built.
new FormData(form) captures exactly what a native submit would send, and fetch(url, { method: "POST", body: formData }) sends it as multipart. Listening to the form's submit event and calling requestSubmit(), rather than submit(), keeps validation and submit handlers in play.
Example
<form id="f" action="/api/profile" method="post" enctype="multipart/form-data" novalidate>
<input name="username" required minlength="3">
<input name="avatar" type="file" accept="image/*">
<input name="confirm" placeholder="type DELETE">
<button name="intent" value="save">Save</button>
</form>
<script>
const f = document.getElementById("f");
f.addEventListener("submit", async (e) => {
e.preventDefault();
const c = f.elements.confirm;
c.setCustomValidity(c.value === "DELETE" ? "" : "Type DELETE to confirm");
if (!f.reportValidity()) return; // shows first error, focuses it
const data = new FormData(f, e.submitter); // includes intent=save
const res = await fetch(f.action, { method: "POST", body: data });
console.log(res.status);
});
</script>Remember
- File uploads need enctype="multipart/form-data"
- Disabled controls are not submitted; readonly ones are
- setCustomValidity("") clears a custom error
- FormData(form, submitter) mirrors a native submit
Watch out for
- Calling setCustomValidity with a message and never resetting it to an empty string.