From react-plugin
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).
How this skill is triggered — by the user, by Claude, or both
Slash command
/react-plugin:react-formsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Forms are where state, validation, accessibility, and UX intersect. Pick the library the project uses; don't introduce a new one without BA approval.
Forms are where state, validation, accessibility, and UX intersect. Pick the library the project uses; don't introduce a new one without BA approval.
| 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 |
pnpm add react-hook-form. Pair with @hookform/resolvers + zod for validation.
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>
</form>
);
}
z.infer).aria-invalid, role="alert".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.
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</button>
</form>
);
}
field.id is a stable React key generated by RHF — DON'T use index as key.
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.
const password = useWatch({ control, name: 'password' });
// Re-renders only this component when 'password' changes
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',
});
// Prefill from server
useEffect(() => {
reset({ email: user.email, name: user.name });
}, [user, reset]);
// Reset after submit
const onSubmit = async (data) => {
await save(data);
reset();
};
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']); // validate only step's fields
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>}
</form>
);
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.
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 type="password" name="password" />
<ErrorMessage name="password" component="div" />
<button type="submit" disabled={isSubmitting}>Submit</button>
</Form>
)}
</Formik>;
Formik renders all fields on every keystroke (controlled inputs) — performance suffers on large forms. Modern projects prefer react-hook-form.
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>
);
}
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.
const createUser = useCreateUser(); // useMutation from TanStack Query
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.
<label> (visible or aria-label).aria-invalid on inputs with errors.role="alert" on error messages (or aria-live="polite").focus() the input, or use react-hook-form's shouldFocusError).react-hook-form and formik — pick one.setState for every field in a controlled setup with 30 fields — performance dies. Use react-hook-form.mode: 'onBlur' or 'onTouched'.mode: 'onBlur'.npx claudepluginhub aratkruglik/claude-sdlc --plugin react-pluginProvides TanStack Form v1 integration with type-safe forms, Zod/Yup/Valibot validation, async validation, arrays, nested fields, and React 19 Server Actions.
Optimizes React Hook Form client-side form validation with useForm, useWatch, useController, useFieldArray, and subscribe() API. Provides 45 performance rules across 8 categories.
Implements React forms using controlled components, React Hook Form with Zod validation, dynamic useFieldArray fields, server actions, optimistic updates, file uploads, and multi-step wizards.