form-handler
Use when designing or reviewing a form — validation timing, error display, submission lifecycle, optimistic UI, dirty/pristine state, idempotency — even on 'why does submit double-fire?'.
SKILL.mdsrc/skills/form-handler/SKILL.md
GitHubform-handler
Pick the validation timing, lay out error placement, sequence the submission lifecycle, and decide where (and whether) to use optimistic UI. Stack-agnostic — Livewire, Inertia, React Hook Form, server-rendered Blade — the lens is the same. Pair with
laravel-validationfor server-side rules andaccessibility-auditorfor label / error a11y.
When to use
- A new form is being designed (login, signup, settings, multi-step wizard) and the validation strategy is unsettled.
- A form bug shows up: double-submission, lost data on validation fail, error not announced, optimistic update flicker.
- A wizard or multi-step flow needs state across steps and the ownership of dirty / pristine state is unclear.
- German triggers: "Formular bauen", "wann validieren?", "Submit feuert doppelt".
Do NOT use when:
- The screen is read-only / display-only — no form, skip.
- The question is purely server-side validation rules — route to
laravel-validation(or the stack equivalent). - The question is form layout / spacing — route to
fe-designortailwind-engineer.
Procedure
1. Identify field types, pick validation timing
Inspect every field on the form (text, password, async-checked, file, multi-step) before choosing timing. Three knobs, picked per field:
| Timing | When |
|---|---|
| On submit only | Forms with privacy concerns (passwords); short forms |
| On blur | Default — feedback when the user finishes the field |
| On change (debounced) | Typing-feedback fields (password strength) |
Avoid validate-on-change for the whole form — premature errors shame the user.
An on-change availability check is an enumeration oracle, not a default.
For any field that IDENTIFIES an account — email, phone, login handle — it
answers "does this account exist?" to anyone who types, at whatever rate the
debounce allows. That is the account-enumeration threat row in
threat-modeling's corpus, and the field being a signup form rather than a
login form changes nothing about what the answer tells an attacker.
It is acceptable only where all three hold: the field is a PUBLIC handle that is not a login identifier, the endpoint is rate-limited per account and per source, and the project has recorded the trade deliberately. Otherwise the check belongs on submit, with a response that does not distinguish taken from free.
2. Lock the error display contract
Per field: error message location (below field), text-based
(never colour-only), aria-describedby wired, focus moves to
first error on submit-fail. Per form: a summary box at top
that lists every error with anchor links — required for forms
5 fields and any wizard step.
3. Sequence the submission lifecycle
Order matters; bake into one helper:
- Disable the submit button (idempotency).
- Run client-side validation; bail on failure with focus to first error.
- Mark optimistic state if applicable (step 5).
- POST to server.
- On 2xx: clear dirty state, show success, route or reset.
- On 4xx: surface field errors per the display contract; restore submit button; do not lose user input.
- On 5xx: keep input, show retry CTA, log to error tracker.
A submit handler that skips step 1 is a duplicate-record bug waiting to fire on slow networks.
4. Decide on optimistic UI per action
Optimistic UI is only safe when rollback is cheap and visible: toggling a like, marking read, in-list reorder. It is unsafe for: multi-field forms, money movements, anything with downstream notifications. Default to non-optimistic; opt in per action with a documented rollback path.
5. Track dirty state and unsaved-changes guard
Pristine = identical to the server's last-known value. Dirty =
any difference. On route-leave with dirty form, prompt
confirmation (browser beforeunload for full nav, in-app modal
for client-side route changes). For wizards, dirty state is
per-step; submit at step N validates only that step's fields,
final submit re-validates the whole payload server-side.
Output format
Return:
- Validation contract — per-field timing + error-display rules.
- Submit lifecycle — the 7-step sequence with any deviations called out.
- Risk surface — optimistic-UI rollback path, dirty guard, idempotency, server contract (endpoint, status codes, error shape).
Concrete shape:
Form: <name / route>
Validation timing: per field — <field: timing>
Error display: below field + summary box (≥ 5 fields)
Submit lifecycle: <list of steps 1–7 with any deviations>
Optimistic UI: <none | per action — list with rollback path>
Dirty guard: <yes/no — and the route-leave hook>
Idempotency: <strategy — disable + token? request id?>
Server contract: <endpoint, status codes, error shape>
Gotcha
- Disabling the submit button without showing a spinner reads as "broken UI"; pair the disable with a visible busy state.
- Validation-on-change for a password creates a bad UX: errors appear before the user has finished typing. Validate-on-blur with a single re-validate-on-change after the first failure.
- Optimistic UI + slow network = the user sees success then a rollback; that is worse than waiting. Pick optimistic only when the request is < 300 ms p95.
- Wizard "save & continue" buttons that POST per step double the failure surface; only do this if the back-end can resume. Otherwise hold state client-side and POST once at the end.
Do NOT
- Do NOT trust client-side validation alone; server-side is the contract. Client-side is UX, not safety.
- Do NOT clear the form on validation failure; the user's input is sacred until the server says it is safely saved.
- Do NOT use colour as the only error signal; that is both an a11y failure and a print / colour-blind regression.
- Do NOT enable optimistic UI by default; the rollback story has to fit on one line, or it does not ship optimistic.