# Form UX

> Build forms with correct loading, success, and error UX using Server Actions + react-hook-form + Zod. Use when adding a new form, after QA reports form bugs, when errors aren't announced or input is lost on submit, or before shipping. Not for general state-store selection (use state-management-decisions) or non-form error/empty UI states (use async-ux-states).

- Skill: `jaykim88/form-ux` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/form-ux`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/form-ux/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/form-ux

---


# 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

1. **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)*

2. **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` *(Zod `safeParse` — see Implementation)*

3. **Wire pending state + double-submit prevention**
   - Disable submit while pending — never trust client-only `disabled` to prevent double-submit (the server must also reject duplicate writes)
   - Drive the disabled/spinner state from the framework's pending signal *(React: `useActionState` isPending / `useFormStatus` pending / RHF isSubmitting — see Implementation)*
   - Show inline spinner or button label change

4. **Display errors at two levels — and announce them**
   - **Field-level**: message under each input, wired via `aria-describedby` + set `aria-invalid` on 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

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

5. **Replace placeholder text with labels**
   - Bind `<label htmlFor>` to input `id`
   - Placeholder is only for example values, never a substitute for label
   - Use the correct `type` / `inputmode` / `autocomplete` per field (email, tel, one-time-code, current-password) — drives the right mobile keyboard and lets password managers autofill (WCAG 1.3.5)

6. **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)*

7. **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-invalid` wired)
   - Double-click / rapid resubmit is rejected (client `disabled` AND 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)**

```tsx
// ❌ 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>.tsx` with all 4 states (idle / pending / success / error)
- **Shared schema**: `src/lib/schemas/<form-name>.ts` exporting 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) or `useFormStatus`
- 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 via `useActionState` state and mapped to each field
- Preserve input on error: `useActionState` keeps 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 RHF `setFocus`)
- 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`'s `isPending` or `useFormStatus().pending`

### Other stacks
- **Vue / Nuxt**: VeeValidate + Zod or Yup; Nuxt has `useFetch` with 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-forms` for 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 Action `safeParse` (security boundary) — and return `flatten().fieldErrors` so the server's errors map back to specific fields. The Server Action is a public endpoint: validate *and* authorize. Use `useActionState`'s `isPending` (React 19 primary) or `useFormStatus().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.

