form-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-validation for
server-side rules and accessibility-auditor
for 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-design or
tailwind-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 (username availability, password strength) |
Avoid validate-on-change for the whole form — premature errors
shame the user. Username-availability is the canonical exception.
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.
1---2name: form-handler3description: 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?'.4---56# form-handler78> Pick the validation timing, lay out error placement, sequence the9> submission lifecycle, and decide where (and whether) to use10> optimistic UI. Stack-agnostic — Livewire, Inertia, React Hook Form,11> server-rendered Blade — the lens is the same. Pair with12> [`laravel-validation`](../laravel-validation/SKILL.md) for13> server-side rules and [`accessibility-auditor`](../accessibility-auditor/SKILL.md)14> for label / error a11y.1516## When to use1718- A new form is being designed (login, signup, settings, multi-step19 wizard) and the validation strategy is unsettled.20- A form bug shows up: double-submission, lost data on validation21 fail, error not announced, optimistic update flicker.22- A wizard or multi-step flow needs state across steps and the23 ownership of dirty / pristine state is unclear.24- German triggers: "Formular bauen", "wann validieren?",25 "Submit feuert doppelt".2627Do NOT use when:2829- The screen is read-only / display-only — no form, skip.30- The question is purely server-side validation rules — route to31 [`laravel-validation`](../laravel-validation/SKILL.md) (or the32 stack equivalent).33- The question is form *layout* / spacing — route to34 [`fe-design`](../fe-design/SKILL.md) or35 [`tailwind-engineer`](../tailwind-engineer/SKILL.md).3637## Procedure3839### 1. Identify field types, pick validation timing4041Inspect every field on the form (text, password, async-checked,42file, multi-step) before choosing timing. Three knobs, picked per43field:4445| Timing | When |46|---|---|47| On submit only | Forms with privacy concerns (passwords); short forms |48| On blur | Default — feedback when the user finishes the field |49| On change (debounced) | Typing-feedback fields (username availability, password strength) |5051Avoid validate-on-change for the whole form — premature errors52shame the user. Username-availability is the canonical exception.5354### 2. Lock the error display contract5556Per field: error message location (below field), text-based57(never colour-only), `aria-describedby` wired, focus moves to58**first** error on submit-fail. Per form: a summary box at top59that lists every error with anchor links — required for forms60> 5 fields and any wizard step.6162### 3. Sequence the submission lifecycle6364Order matters; bake into one helper:65661. Disable the submit button (idempotency).672. Run client-side validation; bail on failure with focus to68 first error.693. Mark optimistic state if applicable (step 5).704. POST to server.715. On 2xx: clear dirty state, show success, route or reset.726. On 4xx: surface field errors per the display contract; restore73 submit button; **do not** lose user input.747. On 5xx: keep input, show retry CTA, log to error tracker.7576A submit handler that skips step 1 is a duplicate-record bug77waiting to fire on slow networks.7879### 4. Decide on optimistic UI per action8081Optimistic UI is only safe when **rollback is cheap and visible**:82toggling a like, marking read, in-list reorder. It is unsafe for:83multi-field forms, money movements, anything with downstream84notifications. Default to non-optimistic; opt in per action with85a documented rollback path.8687### 5. Track dirty state and unsaved-changes guard8889Pristine = identical to the server's last-known value. Dirty =90any difference. On route-leave with dirty form, prompt91confirmation (browser `beforeunload` for full nav, in-app modal92for client-side route changes). For wizards, dirty state is93per-step; submit at step N validates only that step's fields,94final submit re-validates the whole payload server-side.9596## Output format9798Return:991001. Validation contract — per-field timing + error-display rules.1012. Submit lifecycle — the 7-step sequence with any deviations called out.1023. Risk surface — optimistic-UI rollback path, dirty guard, idempotency,103 server contract (endpoint, status codes, error shape).104105Concrete shape:106107```108Form: <name / route>109Validation timing: per field — <field: timing>110Error display: below field + summary box (≥ 5 fields)111Submit lifecycle: <list of steps 1–7 with any deviations>112Optimistic UI: <none | per action — list with rollback path>113Dirty guard: <yes/no — and the route-leave hook>114Idempotency: <strategy — disable + token? request id?>115Server contract: <endpoint, status codes, error shape>116```117118## Gotcha119120- Disabling the submit button without showing a spinner reads as121 "broken UI"; pair the disable with a visible busy state.122- Validation-on-change for a password creates a bad UX: errors123 appear before the user has finished typing. Validate-on-blur124 with a single re-validate-on-change *after* the first failure.125- Optimistic UI + slow network = the user sees success then a126 rollback; that is worse than waiting. Pick optimistic only when127 the request is < 300 ms p95.128- Wizard "save & continue" buttons that POST per step double the129 failure surface; only do this if the back-end can resume.130 Otherwise hold state client-side and POST once at the end.131132## Do NOT133134- Do NOT trust client-side validation alone; server-side is the135 contract. Client-side is UX, not safety.136- Do NOT clear the form on validation failure; the user's input137 is sacred until the server says it is safely saved.138- Do NOT use colour as the only error signal; that is both an a11y139 failure and a print / colour-blind regression.140- Do NOT enable optimistic UI by default; the rollback story has141 to fit on one line, or it does not ship optimistic.