--- name: sdlc-react-forms description: | Form patterns for React: react-hook-form (most common), Formik (legacy/stable), TanStack Form (newer). Validation via zod / yup / valibot. Controlled vs uncontrolled inputs, field arrays, multi-step wizards. Use this skill to: - Wire react-hook-form with a validation schema. - Pick between controlled and uncontrolled patterns. - Build multi-step forms with state preservation. - Handle async validation (e.g., username availability). - Integrate forms with TanStack Query mutations. Do NOT use this skill for: - General state management (see react-state-management). - Routing (see react-routing). - Component conventions (see react-conventions). - Testing forms (see react-testing). paths: ["src/**/*.tsx", "src/**/*.jsx"] --- # React Form Patterns Forms are where state, validation, accessibility, and UX intersect. Pick the library the project uses; don't introduce a new one without BA approval. ## Detection | Marker (in dependencies) | Library | |---|---| | `react-hook-form` | React Hook Form (most common, recommended for new) | | `formik` | Formik (legacy but stable) | | `@tanstack/react-form` | TanStack Form (newer, type-safe) | | (none) | Plain React with `useState` per field — fine for simple forms | | `zod` / `yup` / `valibot` / `joi` | Validation library — pair with the form lib via resolver | ## React Hook Form (recommended) `pnpm add react-hook-form`. Pair with `@hookform/resolvers` + `zod` for validation. ### Basic form ```tsx import { useForm, SubmitHandler } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const Schema = z.object({ email: z.string().email('Invalid email'), password: z.string().min(8, 'At least 8 characters'), remember: z.boolean(), }); type FormData = z.infer; export function LoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(Schema), defaultValues: { email: '', password: '', remember: false }, }); const onSubmit: SubmitHandler = async (data) => { await login(data); }; return (
); } ``` ### Why react-hook-form - Uncontrolled inputs by default — minimal re-renders. - TypeScript inference from schema (via `z.infer`). - Accessibility-friendly: pairs naturally with `aria-invalid`, `role="alert"`. - Performance: 1 re-render per field-touch instead of N (controlled). ### Controller (for non-native inputs) When using a third-party UI library (MUI, Mantine, AntD select, custom components): ```tsx import { Controller } from 'react-hook-form'; import { Select, MenuItem } from '@mui/material'; ( )} /> ``` `Controller` adapts uncontrolled-friendly libraries to controlled APIs. ### Field arrays For dynamic lists of inputs (e.g., add multiple emails): ```tsx import { useFieldArray, useForm } from 'react-hook-form'; type FormData = { contacts: { email: string }[] }; export function ContactsForm() { const { control, register, handleSubmit } = useForm({ defaultValues: { contacts: [{ email: '' }] }, }); const { fields, append, remove } = useFieldArray({ control, name: 'contacts' }); return (
{fields.map((field, index) => (
))}
); } ``` `field.id` is a stable React key generated by RHF — DON'T use index as key. ### Async validation For async checks (e.g., username available?): ```tsx const Schema = z.object({ username: z.string().min(3).refine( async (u) => { const res = await fetch(`/api/users/check?u=${u}`); return (await res.json()).available; }, 'Username taken' ), }); const { register } = useForm({ resolver: zodResolver(Schema), mode: 'onBlur' }); ``` `mode: 'onBlur'` validates after the user leaves the field — appropriate for expensive checks. ### Watching values ```tsx const password = useWatch({ control, name: 'password' }); // Re-renders only this component when 'password' changes ``` For dependent fields (e.g., confirm password matches): ```ts const Schema = z.object({ password: z.string().min(8), confirmPassword: z.string(), }).refine((data) => data.password === data.confirmPassword, { path: ['confirmPassword'], message: 'Passwords do not match', }); ``` ### Reset and prefill ```tsx // Prefill from server useEffect(() => { reset({ email: user.email, name: user.name }); }, [user, reset]); // Reset after submit const onSubmit = async (data) => { await save(data); reset(); }; ``` ### Multi-step forms Two patterns: **Pattern A — single form, conditional UI**: ```tsx const [step, setStep] = useState(0); const { register, handleSubmit, trigger, formState } = useForm({ resolver: zodResolver(Schema), mode: 'onTouched' }); const next = async () => { const valid = await trigger(['email', 'password']); // validate only step's fields if (valid) setStep(step + 1); }; return (
{step === 0 && } {step === 1 && } {step < lastStep ? : } ); ``` **Pattern B — separate sub-forms with shared store** (Zustand): each step has its own form; persist between steps in a store. Harder but better for very long flows. ## Formik (legacy) Still common in older projects. Similar concepts: ```tsx import { Formik, Form, Field, ErrorMessage } from 'formik'; import * as Yup from 'yup'; const Schema = Yup.object({ email: Yup.string().email().required(), password: Yup.string().min(8).required(), }); { await login(values); setSubmitting(false); }} > {({ isSubmitting }) => (
)}
; ``` Formik renders all fields on every keystroke (controlled inputs) — performance suffers on large forms. Modern projects prefer react-hook-form. ## TanStack Form (newer) `pnpm add @tanstack/react-form`. Type-safe, framework-agnostic core. Modern alternative. ```tsx import { useForm } from '@tanstack/react-form'; function MyForm() { const form = useForm({ defaultValues: { email: '', password: '' }, onSubmit: async ({ value }) => { await login(value); }, }); return (
{ e.preventDefault(); form.handleSubmit(); }}> !value.includes('@') ? 'Invalid' : undefined }}> {(field) => ( <> field.handleChange(e.target.value)} /> {field.state.meta.errors.length > 0 &&

{field.state.meta.errors.join(', ')}

} )}
); } ``` ## Plain React (no library) — when sufficient For very simple forms (1-3 fields, no validation library, no async): ```tsx const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
{ e.preventDefault(); login({ email, password }); }}> setEmail(e.target.value)} required /> setPassword(e.target.value)} required />
; ``` Built-in HTML5 validation (`required`, `type="email"`, `pattern`) is free. ## Integration with TanStack Query ```tsx const createUser = useCreateUser(); // useMutation from TanStack Query const { register, handleSubmit, setError, formState } = useForm({ resolver: zodResolver(Schema) }); const onSubmit = handleSubmit(async (data) => { try { await createUser.mutateAsync(data); navigate('/users'); } catch (err) { if (err instanceof FetchError && err.status === 409) { setError('email', { message: 'Email already in use' }); } else { setError('root', { message: 'Something went wrong' }); } } }); ``` `setError('root', ...)` is a special key for form-level errors not tied to a single field. Show via `errors.root?.message`. ## Accessibility checklist - Every input has a `