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:

Field
Type
Description

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