# 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](#form-state-model)
* [Controlled vs Uncontrolled](#controlled-vs-uncontrolled)
* [Validation Architecture](#validation-architecture)
* [Submission Flow](#submission-flow)
* [Reusable Form Hook (TypeScript)](#reusable-form-hook-typescript)
* [Field-Level Error Display](#field-level-error-display)
* [Multi-Step Forms](#multi-step-forms)
* [Lessons Learned](#lessons-learned)

***

## 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.

```tsx
// Controlled — state drives the input's value
const [email, setEmail] = useState("")
<input value={email} onChange={e => setEmail(e.target.value)} />
```

**Uncontrolled:** The DOM manages the value; the component reads it via `ref` only when needed (on submit).

```tsx
// Uncontrolled — DOM manages value, read on submit
const emailRef = useRef<HTMLInputElement>(null)
<input ref={emailRef} />
// On submit: const value = emailRef.current?.value
```

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.

```typescript
// forms/validation.ts

export type ValidationErrors<T> = Partial<Record<keyof T, string>>

export interface ProductFormValues {
  name: string
  price: string
  categoryId: string
}

export function validateProductForm(values: ProductFormValues): ValidationErrors<ProductFormValues> {
  const errors: ValidationErrors<ProductFormValues> = {}

  if (!values.name.trim()) {
    errors.name = "Product name is required"
  } else if (values.name.length > 120) {
    errors.name = "Product name must be 120 characters or fewer"
  }

  const price = parseFloat(values.price)
  if (!values.price) {
    errors.price = "Price is required"
  } else if (isNaN(price) || price <= 0) {
    errors.price = "Price must be a positive number"
  }

  if (!values.categoryId) {
    errors.categoryId = "Please select a category"
  }

  return errors
}
```

Test the validator in isolation:

```typescript
// forms/validation.test.ts
import { validateProductForm } from "./validation"

test("price must be positive", () => {
  const errors = validateProductForm({ name: "Coffee", price: "-10", categoryId: "1" })
  expect(errors.price).toBe("Price must be a positive number")
})
```

***

## Submission Flow

A robust submit handler follows this sequence:

```
1. Set isSubmitting = true
2. Run full validation — if errors, set them + set isSubmitting = false and return
3. Call API
4. On success: navigate / show success state
5. On API error: extract field-level errors from response (if available) or show generic error
6. Set isSubmitting = false in finally block
```

```typescript
async function handleSubmit(values: ProductFormValues) {
  setIsSubmitting(true)
  const validationErrors = validateProductForm(values)
  if (Object.keys(validationErrors).length > 0) {
    setErrors(validationErrors)
    setIsSubmitting(false)
    return
  }

  try {
    await createProduct({ name: values.name, price: parseFloat(values.price), categoryId: parseInt(values.categoryId) })
    navigate("/products")
  } catch (err) {
    if (err instanceof ApiValidationError) {
      // Server returned field-level errors (400 with JSON body)
      setErrors(err.fieldErrors)
    } else {
      setSubmitError("Failed to save product. Please try again.")
    }
  } finally {
    setIsSubmitting(false)
  }
}
```

***

## Reusable Form Hook (TypeScript)

Centralising form logic in a custom hook avoids duplicating state and handlers across every form component.

```typescript
// hooks/useForm.ts

import { useState, useCallback } from "react"

interface UseFormOptions<T> {
  initialValues: T
  validate: (values: T) => Partial<Record<keyof T, string>>
  onSubmit: (values: T) => Promise<void>
}

export function useForm<T extends Record<string, unknown>>({
  initialValues,
  validate,
  onSubmit,
}: UseFormOptions<T>) {
  const [values, setValues] = useState<T>(initialValues)
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [submitError, setSubmitError] = useState<string | null>(null)

  const handleChange = useCallback((field: keyof T, value: unknown) => {
    setValues(prev => ({ ...prev, [field]: value }))
    setErrors(prev => ({ ...prev, [field]: undefined }))
  }, [])

  const handleBlur = useCallback((field: keyof T) => {
    setTouched(prev => ({ ...prev, [field]: true }))
    const validationErrors = validate({ ...values })
    setErrors(prev => ({ ...prev, [field]: validationErrors[field] }))
  }, [values, validate])

  const handleSubmit = useCallback(async (e: React.FormEvent) => {
    e.preventDefault()
    const allTouched = Object.fromEntries(
      Object.keys(values).map(k => [k, true])
    ) as Record<keyof T, boolean>
    setTouched(allTouched)

    const validationErrors = validate(values)
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors)
      return
    }

    setIsSubmitting(true)
    setSubmitError(null)
    try {
      await onSubmit(values)
    } catch (err) {
      setSubmitError(err instanceof Error ? err.message : "An error occurred")
    } finally {
      setIsSubmitting(false)
    }
  }, [values, validate, onSubmit])

  return { values, errors, touched, isSubmitting, submitError, handleChange, handleBlur, handleSubmit }
}
```

Usage:

```tsx
// components/ProductForm.tsx

export function ProductForm() {
  const { values, errors, touched, isSubmitting, submitError, handleChange, handleBlur, handleSubmit } =
    useForm({
      initialValues: { name: "", price: "", categoryId: "" },
      validate: validateProductForm,
      onSubmit: async (values) => {
        await createProduct(values)
        navigate("/products")
      },
    })

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name</label>
        <input
          value={values.name}
          onChange={e => handleChange("name", e.target.value)}
          onBlur={() => handleBlur("name")}
        />
        {touched.name && errors.name && <span className="error">{errors.name}</span>}
      </div>

      {submitError && <div className="submit-error">{submitError}</div>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving…" : "Save Product"}
      </button>
    </form>
  )
}
```

***

## 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.

```typescript
// Helper to check whether an error should be displayed
function showError(field: keyof T): string | undefined {
  return touched[field] ? errors[field] : undefined
}
```

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:

```typescript
type Step = "personal" | "address" | "payment"

function MultiStepForm() {
  const [step, setStep] = useState<Step>("personal")
  const [formData, setFormData] = useState<Partial<FullFormValues>>({})

  function handleStepSubmit(stepValues: Partial<FullFormValues>) {
    setFormData(prev => ({ ...prev, ...stepValues }))
    const steps: Step[] = ["personal", "address", "payment"]
    const nextIndex = steps.indexOf(step) + 1
    if (nextIndex < steps.length) {
      setStep(steps[nextIndex])
    } else {
      submitFinalForm({ ...formData, ...stepValues } as FullFormValues)
    }
  }

  // Render the current step's form component, pass handleStepSubmit as onSubmit
}
```

***

## 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.
