Forms and Controls
The Admin Dashboard That Broke Everything
When I added an admin interface to the POS system, I built the first form the quick way: read input values from the DOM in the submit handler, validate inline, POST to the API. It worked.
Then the second form. Then the third. By the fifth form I had copied the same validation logic into five places, each with a slightly different treatment of the error message format. A change to how required fields were signalled meant updating five files.
Forms are deceptively complex. A form is not just HTML; it has state (current values, dirty flags, validation errors, submission status), behaviour (onChange, onBlur, onSubmit), and side effects (API calls, navigation). Treating it as a pattern gives that complexity a home.
Table of Contents
Form State Model
A form's state at any point in time contains:
values
Record<string, unknown>
Current form field values
errors
Record<string, string>
Per-field validation errors
touched
Record<string, boolean>
Fields the user has interacted with
isSubmitting
boolean
True while the async submit is in flight
isSubmitted
boolean
True after a completed submission attempt
isDirty
boolean
True if any field differs from initial values
This model makes the rules explicit: show an error only if touched[field] is true (do not punish the user before they interact with the field), and disable the submit button when isSubmitting is true.
Controlled vs Uncontrolled
Controlled: The component renders what the state says, and every keypress updates the state. React's useState or a form library keeps a single source of truth.
Uncontrolled: The DOM manages the value; the component reads it via ref only when needed (on submit).
Favour controlled inputs for forms with complex validation, conditional fields, or real-time feedback. Favour uncontrolled only for very simple, rarely-read inputs (e.g. a file upload).
Validation Architecture
Validation belongs in a pure, framework-agnostic function — not inside a component. This makes it testable.
Test the validator in isolation:
Submission Flow
A robust submit handler follows this sequence:
Reusable Form Hook (TypeScript)
Centralising form logic in a custom hook avoids duplicating state and handlers across every form component.
Usage:
Field-Level Error Display
A key UX principle: show errors only after the user has touched the field. Showing errors on page load before the user has done anything is punishing.
Display rules:
On blur: validate the single field the user just left
On submit: validate all fields and mark all as touched so all errors appear
On change: optionally clear the error for the field being edited (re-validate on blur)
Multi-Step Forms
For longer flows like checkout or a setup wizard, split state across steps but validate before proceeding:
Lessons Learned
Validate on the server, always. Client-side validation is for user experience — it does not replace server-side validation. The server is the security boundary.
Field errors from the API should map to form fields. A 400 response body with
{"errors": {"name": "already exists"}}should set the field error in the form, not just show a generic toast.Disable the submit button while submitting. Double-submits cause duplicate orders, duplicate payments, duplicate records. One button disable prevents the whole class of issues.
Reset form state on navigation, not on unmount. Unmounting can happen for reasons other than navigation; resetting only on intentional navigation prevents ghost state.
Last updated