Skip to content
Back to skills

React Forms

ASecurity

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...

  • 35 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 22, 2026
developmenttypescriptreacttestingapiperformance

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 22, 2026

npx -y skills add AratKruglik/claude-sdlc --skill react-forms --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of React Forms?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for React Forms
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/aratkruglik-react-forms/badge)](https://www.skillsdirectory.com/skills/aratkruglik-react-forms)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…