Chapter 24: Forms Are Transactions
A form is not a pile of inputs.
A form is a transaction boundary. The form defines a set of related controls. It names their values. It declares constraints on those values. It owns a submit action and a destination for user intent. The form, in the platform’s design, is the smallest unit at which the browser commits a coherent group of user inputs to the server.
This is the right vocabulary for the chapter, and it’s worth saying directly. Backend systems have spent decades developing the architectural pattern called transaction. A transaction is a group of operations that succeeds or fails as a unit. The values inside the transaction are inconsistent intermediate states; the values after the commit are the consistent result. If anything goes wrong, the transaction rolls back and nothing changes. The pattern is foundational in databases, in distributed systems, in payment processing, and in any system where multiple values have to be updated together.
HTML forms are the browser’s transaction primitive. The user can type, edit, change, and revert values inside the form indefinitely; nothing is committed until the form is submitted. The submit either succeeds (the server accepts the data and responds) or fails (the server rejects, validation prevents it, the network fails) — and the application’s state has a clean answer either way. The form models the commit point.
Modern frontend often treats forms as state-management problems. The argument isn’t wrong — complex forms do require state. But if we reduce forms to controlled inputs and submit handlers, we lose the native transaction model the browser already provides. This chapter argues for taking the form seriously again, and for using the form-associated capabilities the platform shipped in 2020 to let custom components participate in forms as first-class members.
The Form as Boundary
Section titled “The Form as Boundary”A form groups related controls:
<form action="/profile" method="post"> <label for="display-name">Display name</label> <input id="display-name" name="displayName" required>
<label for="email">Email</label> <input id="email" name="email" type="email" required>
<button type="submit">Save profile</button></form>This boundary does a lot of work that’s easy to take for granted.
The submit button is associated with the form. Pressing Enter in any of the form’s fields triggers submission. Clicking the submit button submits. The button receives the form’s submission semantics for free. A type="submit" button inside a form submits that form; a button outside the form, or with type="button", does not.
The input name attributes become keys in the submitted data. When the form submits, the browser produces a request body containing displayName=...&email=..., derived from the inputs’ name attributes and current values. The names are the contract between the form and the server.
The required attribute participates in the browser’s constraint validation. Submitting a form with an empty required field doesn’t reach the server — the browser blocks the submission and shows a validation message. The form’s behavior is largely a function of attributes declared on its controls.
Password managers can read the form. Browser autofill can suggest values. Screen readers can navigate the labels and announce the controls correctly. Mobile keyboards adapt to the input types (type="email" shows the email-friendly keyboard, type="tel" shows the phone-number keyboard, inputmode="numeric" shows numeric keys). Each of these affordances comes from the platform recognizing the form structure.
The form is not visual grouping. It’s a structural commitment that unlocks the platform’s accumulated form-handling behavior.
Native Validation
Section titled “Native Validation”The Constraint Validation API has been part of HTML5 since the spec settled in the early 2010s, and the browsers have been steadily improving their implementations since.
Form controls can declare constraints through attributes — required, pattern, min, max, step, minlength, maxlength, type — and the browser enforces them at submission time. A required empty field blocks the submit. A type="email" field with an invalid email address blocks the submit. A pattern="\d{5}" field whose value isn’t five digits blocks the submit. The browser shows a default validation message, focuses the first invalid field, and lets the application observe the validity state through the invalid event.
CSS can style invalid fields with the :invalid pseudo-class, valid fields with :valid, fields the user has interacted with using :user-invalid (the more recent, more useful version that only applies after interaction). The styling is automatic; no JavaScript is required.
JavaScript can inspect validity programmatically:
form.checkValidity() // returns true/falseinput.validity // ValidityState objectinput.validationMessage // the browser's messageinput.setCustomValidity('Custom error message') // overrideThe custom-validity API lets the application participate in the platform’s validation system. An application that needs to verify a username’s uniqueness against the server can mark the field invalid (input.setCustomValidity('That username is taken')) and let the browser’s validation flow handle the rest — focusing the field, showing the message, blocking submission.
This isn’t a complete solution. Server-side validation is still required (client-side validation is convenience, not security). Complex multi-field business rules may need custom logic. Async validation against the server has its own coordination patterns. The native validation system is a first layer, not a complete one.
A modern architecture shouldn’t discard the first layer by default. Most application form-validation needs are well-served by required, type, pattern, and setCustomValidity. Reaching for a full validation library is sometimes necessary and often premature.
FormData as the Data Boundary
Section titled “FormData as the Data Boundary”FormData is the platform’s native way to extract a structured payload from a form:
const data = new FormData(form)
// Read individual values:data.get('displayName') // 'Jeremy Harper'data.get('email') // 'jeremy@example.com'
// Iterate:for (const [key, value] of data) { /* ... */ }
// Convert to plain object:const obj = Object.fromEntries(data)
// Send to a server:fetch('/profile', { method: 'POST', body: data })The FormData object can be passed directly to fetch as the request body, which produces a multipart/form-data request — the same encoding the browser uses for a normal form submission. The server receives the same shape it would have received from a non-AJAX form submit. The application can intercept the submit event, do something interesting in JavaScript, and still submit the form through the standard protocol if it wants to.
This matters because it means the form can hold its own state. Not every keystroke has to update a JavaScript state object. Not every field has to be controlled. The browser already maintains the form’s current values; FormData reads them when the application needs them, usually at submission time.
Framework-controlled forms can be useful, especially for complex interactions — multi-step wizards, real-time validation against a server, optimistic updates, draft persistence. The trade-off is that controlled forms re-implement what the platform already provides. A simple form doesn’t need a state library. The form is the state library, with thirty years of browser-side engineering already done.
The Remix and React Router 7 approach (Chapter 15) leans into this. A <Form> component in those frameworks is a thin wrapper around <form>. Submission produces a FormData payload. An action function receives the payload server-side. The pattern is the form is the API, and the framework’s job is to make this pattern more ergonomic, not to replace it with controlled state.
Events Around Forms
Section titled “Events Around Forms”Forms emit several events the application can observe.
submit fires when the form is submitted. The application can call event.preventDefault() to stop the default submission and handle the data in JavaScript. This is the most-used form event in application code.
input fires on individual form controls as the user types. The application can react to changing values — for live validation, for showing character counts, for filtering a list as the user searches.
change fires when a form control’s value commits — when a checkbox is toggled, when a <select> value changes, when an <input> loses focus after being edited. The semantics are slightly different from input (which fires on every keystroke).
invalid fires on a form control when its validity state becomes invalid. The application can observe this and respond, often by showing a custom error message.
reset fires when the form is reset (form.reset() or a <button type="reset">).
In Kitsune’s metadata protocol, these native events can be observed by a boundary and translated into application-level semantic events — form.submit_requested, form.validation_failed, profile.saved. The form remains a real <form>; the runtime adds the application-level vocabulary on top. Modules subscribed to the runtime see profile.saved as a fact; the form behind it doesn’t have to know which modules care.
A draft module might observe input events on form fields and persist drafts to local storage. A validation module might handle async validity checks. A notification module might respond to profile.saved by showing a toast. An analytics module might track successful submission. An audit module might record certain domain-significant actions. None of these modules has to be imported into the form component. The form announces what happened; the modules observe.
Custom Form Components: Form-Associated Custom Elements
Section titled “Custom Form Components: Form-Associated Custom Elements”Custom elements complicate forms.
A <custom-date-picker> element rendered inside a form doesn’t participate in the form by default. Its value doesn’t appear in FormData. It doesn’t contribute to the form’s validity. It doesn’t reset when the form resets. It’s a custom element that happens to be inside a form, but the platform doesn’t know to treat it as a form member.
The platform’s answer is form-associated custom elements, a 2020 addition that became reliable across browsers around 2022. A custom element can opt in to form participation:
class DatePicker extends HTMLElement { static formAssociated = true
constructor() { super() this.internals = this.attachInternals() }
setValue(value) { this.value = value this.internals.setFormValue(value) }
formResetCallback() { this.setValue(this.defaultValue) }
// ...}
customElements.define('custom-date-picker', DatePicker)The static formAssociated = true declaration tells the platform this element wants to participate in forms. attachInternals() returns an ElementInternals instance that gives the custom element access to form-participation APIs — setFormValue to contribute to FormData, setValidity to participate in constraint validation, setFormDisabled to respond to a parent fieldset’s disabled state, and several others.
A form-associated custom element looks, to the form, like a native form control. Its value appears in FormData. It participates in validation. It resets when the form resets. It can be styled with :invalid, :valid, :disabled. The form’s submit fires whether or not the custom element exists; if it does, its value comes along.
This is one of the more important platform additions of the past several years for application architecture. Before form-associated custom elements, custom UI components either lived inside forms as second-class members (requiring manual JavaScript glue to coordinate values) or didn’t try to participate in forms at all. After form-associated custom elements, a Lit-authored or vanilla custom element can be a first-class form participant.
Kitsune’s component library uses this extensively. <kit-form>, <kit-input>, <kit-select>, <kit-toggle> — each one is a form-associated custom element that integrates cleanly with native form behavior. The chapter on building those components (Ch 49) goes deeper. The point for now is that the platform has made this possible, and modern architecture should take advantage.
Progressive Enhancement: The Form Without JavaScript
Section titled “Progressive Enhancement: The Form Without JavaScript”A working form is the canonical example of progressive enhancement in practice.
The form above, with no JavaScript at all, still works. The user fills in the fields. The browser validates required and email constraints. The user clicks Save. The browser submits a POST request to /profile with displayName=...&email=.... The server processes the submission and returns a response (a new HTML page, a redirect, or whatever). The user sees the result.
This is useful failure. If JavaScript fails to load, fails to parse, fails to execute, or fails to attach event handlers, the form still works. The user can complete the transaction. The application degrades to a slower, less ergonomic experience — full-page reload, no client-side validation message, no optimistic UI — but the core function is preserved.
Adding JavaScript on top of this form can make the experience better. Intercept the submit, send the data asynchronously, update the UI without a full reload, show inline validation messages, persist drafts as the user types. Each enhancement adds a behavior that didn’t exist before. None of them takes away the underlying working form.
This is the enhancement model in progressive enhancement — the platform provides a working baseline, the JavaScript improves it. The alternative model — a <div id="root"> that JavaScript inflates into a working UI — is a single point of failure. If anything in the JavaScript pipeline fails, the user sees nothing.
The Remix and React Router 7 design, the htmx approach, and the broader server-rendering-with-progressive-enhancement movement (Jeremy Keith’s writing, Aaron Gustafson’s Adaptive Web Design, the Smashing Magazine community) have been making this argument for years. The form is the cleanest case where the argument lands. A working form, server-rendered, with JavaScript enhancement, is more resilient and more accessible than the equivalent client-side-only form, and the cost of building it that way is usually lower than the cost of the controlled-state alternative.
What This Means for Modern Frontend
Section titled “What This Means for Modern Frontend”Forms teach an important architectural lesson. User intent often has boundaries.
A save action isn’t just a click. It’s a transaction over a set of values in a context. The values belong together. The submission commits them as a group. The validation applies to the group. The audit log records the group. The analytics event names the group. The transaction is the unit; the individual values are participants.
Modern frontend should use forms to preserve this structure. JavaScript can enhance, validate, submit asynchronously, save drafts, and emit semantic events around the form’s lifecycle. The form should still express the transaction in markup, with named fields, declared constraints, a submit action, and the browser’s full form-handling machinery underneath.
This lets web-native architecture combine the old strengths (working without JavaScript, accessibility, autofill, validation, progressive enhancement) with modern capabilities (custom form-associated components, async submission, optimistic UI, semantic event emission, runtime modules observing the flow).
What Comes Next
Section titled “What Comes Next”The next chapter takes the same architectural-respect view of CSS. CSS is the platform’s runtime adaptation system — custom properties, cascade layers, container queries, :has(), scope, the View Transitions API. Most modern frontend treats CSS as styling output; the chapter argues for treating it as a runtime in its own right, with adaptation, scoping, and reactivity all available without JavaScript.
Exercise: Build a Profile Form Transaction
Section titled “Exercise: Build a Profile Form Transaction”Build a profile form:
<form id="profile-form" data-meta-event="profile.submit_requested" data-entity-type="profile" data-entity-id="me">
<label for="display-name">Display name</label> <input id="display-name" name="displayName" required>
<label for="email">Email</label> <input id="email" name="email" type="email" required>
<button type="submit">Save</button></form>Add JavaScript that:
- Listens for the form’s
submitevent. - Calls
event.preventDefault()to stop the default submission. - Lets the browser’s native constraint validation run (
form.checkValidity()). - If invalid, logs a
form.validation_failedevent with information about which fields failed. - If valid, reads the form’s data with
new FormData(form). - Submits the data with
fetch('/profile', { method: 'POST', body: data })(or just logs it for the exercise). - On success, logs
profile.saved.
Reflect on:
- What did the form provide natively? (Required fields, email validation, submit semantics, label-input association, autofill.)
- What did JavaScript add? (Async submission, custom event emission, async error handling.)
- Which behavior belonged to the form? (Validation, value extraction, submission protocol.)
- Which behavior belonged to modules? (Analytics, audit, draft persistence, notifications.)
- What data should NOT be sent to analytics? (The user’s email value; the password if there were one; any user-supplied free-text content. The event name is safe; the field values aren’t.)
- If JavaScript fails to load, does the form still work? (It should — the native submit fires, the server receives a normal POST, the form is still usable.)
The goal is to treat forms as transaction boundaries rather than as input containers. The form is the smallest unit at which the user commits intent. Build everything else — analytics, audit, drafts, async submission — around it, not inside it.