Back to skills
SKILL.md
React Forms
ASecurityForm 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...
- 35 stars
- 0 votes
- 0 copies
- 2 views
- Added September 22, 2026
Works with
Security analysis
92/100- Installs packages at runtime which could introduce malicious dependencies
npx -y skills add AratKruglik/claude-sdlc --skill react-forms --agent claude-codeAre you the author of React Forms?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/aratkruglik-react-forms)---
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).
user-invocable: false
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<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>
);
}
```
### 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
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):
```tsx
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.
### 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<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.
## 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(),
});
<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.
## 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 (
<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):
```tsx
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
```tsx
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`.
## 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.
Attribution
Comments
Loading comments…