plugins/frontend-toolkit/skills/form-ux/SKILL.md
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).
npx skillsauth add jaykim88/claude-ai-engineering form-uxInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
Choose the form strategy — match the binding to what the form actually needs:
useActionState — see Implementation)pending (e.g. a submit button nested inside the form) — (React 19: useFormStatus — see Implementation)Define ONE validation schema imported by both sides
security-audit)safeParse(...).error.flatten().fieldErrors) so the form maps each error back to its field — not just a generic top-level messagelib/schemas/<form-name>.ts (Zod safeParse — see Implementation)Wire pending state + double-submit prevention
disabled to prevent double-submit (the server must also reject duplicate writes)useActionState isPending / useFormStatus pending / RHF isSubmitting — see Implementation)Display errors at two levels — and announce them
aria-describedby + set aria-invalid on the input; map server field-errors (step 2) back to their fieldsaria-live / role="alert" region so screen readers announce it4b. On a failed submit
Replace placeholder text with labels
<label htmlFor> to input idtype / 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)Handle success
useOptimistic — see Implementation)Verify (validation loop)
aria-live / aria-invalid wired)disabled AND server idempotent)| 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 |
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>
);
}
aria-describedby + aria-invalid; form-level errors announced (aria-live/role="alert")<FormName>.tsx with all 4 states (idle / pending / success / error)src/lib/schemas/<form-name>.ts exporting Zod schema used by both client (RHF resolver) and server (safeParse)schema.safeParse(formData) validationfeat(form): add <form-name> with the 4-state machine + shared schemauseActionState (React 19, recommended) or useFormStatusreact-hook-form + zodResolver + Server ActionsafeParse) shared between client and serverschema.safeParse(formData).error?.flatten().fieldErrors, returned via useActionState state and mapped to each fielduseActionState keeps the prior state across submits; echo submitted values back in the returned state so a no-JS submit doesn't wipe input[aria-invalid] field (useEffect + ref, or RHF setFocus)useOptimistic (React 19)useActionState's isPending or useFormStatus().pendinguseFetch with built-in pending state; server validation in ~/server/api/use:enhance@ngneat/reactive-forms for typed forms); validators run sync/asyncstate-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 patternszodResolver (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.development
Audit and optimize third-party scripts — analytics, tag managers, chat widgets, embeds — with the right loading strategy, performance budget, facades, and CSP/consent controls. Use when adding a script, when TBT/INP regress, when a GDPR/CCPA consent requirement arises, or before shipping. Not for first-party bundle size (use bundle-optimization) or broad Core Web Vitals diagnosis (use rendering-performance).
development
Apply the Testing Trophy (mostly integration tests with RTL + MSW, sparing E2E with Playwright) and set coverage thresholds. Use before new feature work, after bug fixes, when CI coverage falls below target, or when tests are flaky or break on every refactor. Not for wiring coverage gates + Playwright into the GitHub Actions matrix (use cicd-pipeline) or auditing WCAG a11y compliance (use accessibility-audit).
development
Inventory and prioritize technical debt — TODO/FIXME/HACK, any usage, deprecated APIs, untested logic — with impact × effort matrix. Use at quarter start, before a refactoring sprint, when a new teammate joins, or when feature velocity slows. Not for actually paying down debt (use code-refactoring) or recording a migration approach (use decision-records) — this only inventories and prioritizes.
development
Decision framework for choosing the right state location — URL, server cache, local component, or shared/global store. Use when state-sync bugs appear, prop drilling gets deep (3+ levels), filters/tabs lose state on reload, or quarterly review. Not for form state specifically (use form-ux) or when the state is actually server data (use api-caching-optimization).