Form UX
Purpose
Every form handles submit / loading / success / error states correctly, prevents double-submit, and shares validation logic between client and server.
Universal — the 4-state form pattern (idle / pending / success / error), double-submit prevention, shared client-server validation schema, and 2-level error display apply to any framework. The default Procedure illustrates them with React 19 Server Actions (useActionState / useFormStatus / useOptimistic); the Other stacks section maps each to Superforms / VeeValidate / Angular Reactive Forms.
Procedure
Choose the form strategy — match the binding to what the form actually needs:
- (a) Server-mutation forms that need pending state AND server validation errors surfaced back to the form (the common case for forms that write server state) — (React 19:
useActionState— see Implementation) - (b) Child components needing only
pending(e.g. a submit button nested inside the form) — (React 19:useFormStatus— see Implementation) - (c) Complex client validation / multi-step / conditional fields — drive validation client-side, then still mutate on the server — (react-hook-form — see Implementation)
- (d) Client-only state with no server submission — (react-hook-form — see Implementation)
- (a) Server-mutation forms that need pending state AND server validation errors surfaced back to the form (the common case for forms that write server state) — (React 19:
Define ONE validation schema imported by both sides
- The client imports it for UX validation; the server imports it as the security boundary — the server NEVER trusts client validation
- The server mutation is a public endpoint — anyone can call it directly, bypassing your UI. Schema validation is not authorization: check auth/permissions on the server too (see
security-audit) - On failure, return field-keyed errors (
safeParse(...).error.flatten().fieldErrors) so the form maps each error back to its field — not just a generic top-level message - Single shared file in
lib/schemas/<form-name>.ts(ZodsafeParse— see Implementation)
Wire pending state + double-submit prevention
- Disable submit while pending — never trust client-only
disabledto prevent double-submit (the server must also reject duplicate writes) - Drive the disabled/spinner state from the framework's pending signal (React:
useActionStateisPending /useFormStatuspending / RHF isSubmitting — see Implementation) - Show inline spinner or button label change
- Disable submit while pending — never trust client-only
Display errors at two levels — and announce them
- Field-level: message under each input, wired via
aria-describedby+ setaria-invalidon the input; map server field-errors (step 2) back to their fields - Form-level: at the top, for errors not tied to a field; render in an
aria-live/role="alert"region so screen readers announce it
- Field-level: message under each input, wired via
4b. On a failed submit
- Move focus to the first invalid field (or the form-level error) — required for keyboard / screen-reader users (WCAG 3.3.1)
- Preserve the user's entered values — never reset the form on failure; with progressive-enhancement Server Actions, echo submitted values back so a no-JS submit doesn't wipe input
- Validation timing: validate on submit first; only after the first failed submit, re-validate on blur/change — don't error-spam while the user is still typing
Replace placeholder text with labels
- Bind
<label htmlFor>to inputid - Placeholder is only for example values, never a substitute for label
- Use the correct
type/inputmode/autocompleteper field (email, tel, one-time-code, current-password) — drives the right mobile keyboard and lets password managers autofill (WCAG 1.3.5)
- Bind
Handle success
- Redirect after the mutation, or reset form state + show a toast
- For instant-feedback UX (like / comment / drag-reorder), use optimistic UI — render the optimistic result immediately and roll back on error (React:
useOptimistic— see Implementation)
Verify (validation loop)
- Exercise all 4 states: idle / pending / success / error
- Keyboard-only: Tab through every field, submit with Enter, confirm focus lands on the first error after a failed submit
- Screen reader announces field- and form-level errors (
aria-live/aria-invalidwired) - Double-click / rapid resubmit is rejected (client
disabledAND server idempotent) - Failed submit preserves entered values
- Loop until all 4 states + keyboard + SR + double-submit checks pass
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | No server-side validation/authorization (trusts the client only); double-submit writes duplicate records | Block release; fix immediately |
| Major | Failed submit wipes user input; no focus-to-error or errors not announced; placeholder used instead of a label; missing pending state (double-submit possible) | Fix this sprint |
| Minor | Wrong input type/autocomplete; no unsaved-changes guard on a long form; validation fires before the first submit |
Schedule within 2 sprints |
Before / After
Server Action: useActionState is the modern primary path (React 19)
// ❌ useFormStatus only — can't surface server validation errors back to the form
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
}
// no way for parent to know about server errors except re-fetching
// ✅ useActionState surfaces both pending AND server errors
function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, { error: null });
return (
<form action={formAction}>
{state.error && <p role="alert">{state.error}</p>}
<input name="email" />
<button disabled={isPending}>{isPending ? 'Saving…' : 'Save'}</button>
</form>
);
}
Completion Criteria
- All 4 states (idle / pending / success / error) implemented
- Double-submit prevention verified (rapid double-click test)
- Zod schema shared between client and server
- Server mutation checks auth/permissions, not just schema validation
- Field-level errors connected via
aria-describedby+aria-invalid; form-level errors announced (aria-live/role="alert") - Failed submit moves focus to the first error and preserves entered values
Output
- Form component:
<FormName>.tsxwith all 4 states (idle / pending / success / error) - Shared schema:
src/lib/schemas/<form-name>.tsexporting Zod schema used by both client (RHF resolver) and server (safeParse) - Server endpoint: Server Action / Route Handler with
schema.safeParse(formData)validation - Tests: integration test covering 4 states; double-submit prevention test
- Commit format:
feat(form): add <form-name>with the 4-state machine + shared schema
Implementation
React + Next.js (default)
- Server mutation: Server Action +
useActionState(React 19, recommended) oruseFormStatus - Complex client validation:
react-hook-form+zodResolver+ Server Action - Schema: Zod (
safeParse) shared between client and server - Server→field errors:
schema.safeParse(formData).error?.flatten().fieldErrors, returned viauseActionStatestate and mapped to each field - Preserve input on error:
useActionStatekeeps the prior state across submits; echo submitted values back in the returned state so a no-JS submit doesn't wipe input - Focus-on-error: after submit, focus the first
[aria-invalid]field (useEffect+ref, or RHFsetFocus) - Authorization: check the session in the Server Action body before mutating — it runs even if the client never rendered the form
- Optimistic UX:
useOptimistic(React 19) - Pending state:
useActionState'sisPendingoruseFormStatus().pending
Other stacks
- Vue / Nuxt: VeeValidate + Zod or Yup; Nuxt has
useFetchwith built-in pending state; server validation in~/server/api/ - SvelteKit: Superforms + Zod (the de facto SvelteKit form library — gold standard); progressive enhancement via
use:enhance - Angular: Reactive Forms + Zod (via
@ngneat/reactive-formsfor typed forms); validators run sync/async - Universal: shared schema pattern (write once, validate on both sides) works with any TypeScript validation library (Zod, Yup, Valibot, ArkType); double-submit prevention via disabled-while-pending is framework-agnostic
Related skills
state-management-decisions— form state classification (RHF vs useReducer vs useState)accessibility-audit— form a11y (label binding, aria-describedby for errors)async-ux-states— server-error display falls under error UX patterns
Reference
- Key insight encoded: Factor Zod schemas into a shared module imported by both the RHF
zodResolver(UX) and the Server ActionsafeParse(security boundary) — and returnflatten().fieldErrorsso the server's errors map back to specific fields. The Server Action is a public endpoint: validate and authorize. UseuseActionState'sisPending(React 19 primary) oruseFormStatus().pending(child components) to gate the submit button — never trust client-only disabled state to prevent double-submit. Error UX is half the skill: announce errors (aria-live/aria-invalid), move focus to the first invalid field on failure, and never wipe entered values.