| name | 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).
|
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
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<typeof Schema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { email: '', password: '', remember: false },
});
const onSubmit: SubmitHandler<FormData> = async (data) => {
await login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
Email
<input type="email" {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</label>
<label>
Password
<input type="password" {...register('password')} aria-invalid={!!errors.password} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</label>
<label>
<input type="checkbox" {...register('remember')} />
Remember me
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log in'}
</button>
);
}
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):
import { Controller } from 'react-hook-form';
import { Select, MenuItem } from '@mui/material';
<Controller
name="role"
control={control}
render={({ field, fieldState }) => (
<Select {...field} error={!!fieldState.error}>
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="user">User</MenuItem>
</Select>
)}
/>
Controller adapts uncontrolled-friendly libraries to controlled APIs.
Field arrays
For dynamic lists of inputs (e.g., add multiple emails):
import { useFieldArray, useForm } from 'react-hook-form';
type FormData = { contacts: { email: string }[] };
export function ContactsForm() {
const { control, register, handleSubmit } = useForm<FormData>({
defaultValues: { contacts: [{ email: '' }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
return (
<form onSubmit={handleSubmit(...)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`contacts.${index}.email`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ email: '' })}>Add</>
);
}
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?):
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
const password = useWatch({ control, name: 'password' });
For dependent fields (e.g., confirm password matches):
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
useEffect(() => {
reset({ email: user.email, name: user.name });
}, [user, reset]);
const onSubmit = async (data) => {
await save(data);
reset();
};
Multi-step forms
Two patterns:
Pattern A — single form, conditional UI:
const [step, setStep] = useState(0);
const { register, handleSubmit, trigger, formState } = useForm<FormData>({ resolver: zodResolver(Schema), mode: 'onTouched' });
const next = async () => {
const valid = await trigger(['email', 'password']);
if (valid) setStep(step + 1);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 0 && <Step1 register={register} errors={formState.errors} />}
{step === 1 && <Step2 register={register} errors={formState.errors} />}
{step < lastStep ? <button type="button" onClick={next}>Next</button> : <button type="submit">Submit</button>}
</>
);
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:
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(),
});
<Formik
initialValues={{ email: '', password: '' }}
validationSchema={Schema}
onSubmit={async (values, { setSubmitting }) => {
await login(values);
setSubmitting(false);
}}
>
{({ isSubmitting }) => (
<Form>
<Field type="email" name="email" />
<ErrorMessage name="email" component="div" />
<Field = = />
Submit
)}
;
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.
import { useForm } from '@tanstack/react-form';
function MyForm() {
const form = useForm({
defaultValues: { email: '', password: '' },
onSubmit: async ({ value }) => {
await login(value);
},
});
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field name="email" validators={{ onChange: ({ value }) => !value.includes('@') ? 'Invalid' : undefined }}>
{(field) => (
<>
<input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
{field.state.meta.errors.length > 0 && <p>{field.state.meta.errors.join(', ')}</p>}
</>
)}
</form.Field>
<button type="submit">Submit</button>
</form>
);
}
Plain React (no library) — when sufficient
For very simple forms (1-3 fields, no validation library, no async):
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
<form onSubmit={(e) => { e.preventDefault(); login({ email, password }); }}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<button>Submit</button>
</form>;
Built-in HTML5 validation (required, type="email", pattern) is free.
Integration with TanStack Query
const createUser = useCreateUser();
const { register, handleSubmit, setError, formState } = useForm<FormData>({ 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
<label> (visible or aria-label).
aria-invalid on inputs with errors.
role="alert" on error messages (or aria-live="polite").
- Focus moves to the first error after submit (
focus() the input, or use react-hook-form's shouldFocusError).
- Submit button shows loading state (disabled + visual indicator).
Anti-patterns
- ❌ Adding both
react-hook-form and formik — pick one.
- ❌ Calling
setState for every field in a controlled setup with 30 fields — performance dies. Use react-hook-form.
- ❌ Skipping validation on the client AND server — always validate on both.
- ❌ Showing errors before user has touched the field. Use
mode: 'onBlur' or 'onTouched'.
- ❌ Forgetting to disable the submit button while submitting — leads to double-submits.
- ❌ Storing form values in a global store while the form is open — local state is fine.
- ❌ Validating on every keystroke for expensive checks (DB lookup) — debounce or use
mode: 'onBlur'.
- ❌ Resetting the form to empty after a server error — keeps user trapped re-typing.