Claude-skill-registry form-react
Production-ready React form patterns using React Hook Form (default) and TanStack Form with Zod integration. Use when building forms in React applications. Implements reward-early-punish-late validation timing.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/form-react" ~/.claude/skills/majiayu000-claude-skill-registry-form-react && rm -rf "$T"
manifest:
skills/data/form-react/SKILL.mdsource content
Form React
Production React form patterns. Default stack: React Hook Form + Zod.
Quick Start
npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // 1. Define schema const schema = z.object({ email: z.string().email('Invalid email'), password: z.string().min(8, 'Min 8 characters') }); type FormData = z.infer<typeof schema>; // 2. Use form function LoginForm() { const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema), mode: 'onBlur' // Reward early, punish late }); return ( <form onSubmit={handleSubmit(data => console.log(data))}> <input {...register('email')} type="email" autoComplete="email" /> {errors.email && <span>{errors.email.message}</span>} <input {...register('password')} type="password" autoComplete="current-password" /> {errors.password && <span>{errors.password.message}</span>} <button type="submit">Sign in</button> </form> ); }
When to Use Which
| Criteria | React Hook Form | TanStack Form |
|---|---|---|
| Performance | ✅ Best (uncontrolled) | Good (controlled) |
| Bundle size | 12KB | ~15KB |
| TypeScript | Good | ✅ Excellent |
| Cross-framework | ❌ React only | ✅ Multi-framework |
| React Native | Requires workarounds | ✅ Native support |
| Built-in async validation | Manual | ✅ Built-in debouncing |
| Ecosystem | ✅ Mature (4+ years) | Growing |
Default: React Hook Form — Better performance for most React web apps.
Use TanStack Form when:
- Building cross-framework component libraries
- Need strict controlled component behavior
- Heavy async validation (username checks)
- React Native applications
React Hook Form Patterns
Basic Form
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { loginSchema, type LoginFormData } from './schemas'; export function LoginForm({ onSubmit }: { onSubmit: (data: LoginFormData) => void }) { const { register, handleSubmit, formState: { errors, isSubmitting, touchedFields } } = useForm<LoginFormData>({ resolver: zodResolver(loginSchema), mode: 'onBlur', // First validation on blur (punish late) reValidateMode: 'onChange' // Re-validate on change (real-time correction) }); return ( <form onSubmit={handleSubmit(onSubmit)} noValidate> <div className="form-field"> <label htmlFor="email">Email</label> <input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email} {...register('email')} /> {touchedFields.email && errors.email && ( <span role="alert">{errors.email.message}</span> )} </div> <div className="form-field"> <label htmlFor="password">Password</label> <input id="password" type="password" autoComplete="current-password" aria-invalid={!!errors.password} {...register('password')} /> {touchedFields.password && errors.password && ( <span role="alert">{errors.password.message}</span> )} </div> <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Signing in...' : 'Sign in'} </button> </form> ); }
Reusable Form Field Component
// FormField.tsx import { useFormContext } from 'react-hook-form'; import { ReactNode } from 'react'; interface FormFieldProps { name: string; label: string; type?: string; autoComplete?: string; hint?: string; required?: boolean; children?: ReactNode; } export function FormField({ name, label, type = 'text', autoComplete, hint, required, children }: FormFieldProps) { const { register, formState: { errors, touchedFields } } = useFormContext(); const error = errors[name]; const touched = touchedFields[name]; const showError = touched && error; const showValid = touched && !error; return ( <div className={`form-field ${showError ? 'error' : ''} ${showValid ? 'valid' : ''}`}> <label htmlFor={name}> {label} {required && <span aria-hidden="true">*</span>} </label> {hint && <span className="hint">{hint}</span>} {children || ( <input id={name} type={type} autoComplete={autoComplete} aria-invalid={!!error} aria-describedby={error ? `${name}-error` : undefined} {...register(name)} /> )} {showError && ( <span id={`${name}-error`} role="alert" className="error-message"> {error.message as string} </span> )} </div> ); }
Using FormProvider for Nested Components
// Form wrapper import { FormProvider, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; export function CheckoutForm() { const methods = useForm({ resolver: zodResolver(checkoutSchema), mode: 'onBlur' }); return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <ContactSection /> <ShippingSection /> <PaymentSection /> <button type="submit">Place Order</button> </form> </FormProvider> ); } // Nested section component function ContactSection() { return ( <fieldset> <legend>Contact Information</legend> <FormField name="email" label="Email" type="email" autoComplete="email" required /> <FormField name="phone" label="Phone" type="tel" autoComplete="tel" /> </fieldset> ); }
Watching Values (Real-time)
import { useForm, useWatch } from 'react-hook-form'; function RegistrationForm() { const { register, control } = useForm(); // Watch password for strength meter const password = useWatch({ control, name: 'password', defaultValue: '' }); return ( <form> <input type="password" {...register('password')} /> <PasswordStrength password={password} /> </form> ); }
Conditional Fields
import { useFormContext, useWatch } from 'react-hook-form'; function ConditionalField({ watchField, condition, children }) { const { control } = useFormContext(); const value = useWatch({ control, name: watchField }); if (!condition(value)) return null; return <>{children}</>; } // Usage <ConditionalField watchField="hasCompany" condition={(val) => val === true}> <FormField name="companyName" label="Company Name" /> </ConditionalField>
Async Validation (Username Check)
import { useForm } from 'react-hook-form'; function RegistrationForm() { const { register, setError, clearErrors } = useForm(); const checkUsername = async (username: string) => { if (username.length < 3) return; const response = await fetch(`/api/check-username?u=${username}`); const { available } = await response.json(); if (!available) { setError('username', { type: 'manual', message: 'Username is taken' }); } else { clearErrors('username'); } }; return ( <input {...register('username')} onBlur={(e) => checkUsername(e.target.value)} /> ); }
Form Reset
function EditProfileForm({ defaultValues }) { const { reset, handleSubmit } = useForm({ defaultValues }); // Reset to new values useEffect(() => { reset(defaultValues); }, [defaultValues, reset]); // Reset to initial values const handleCancel = () => reset(); return ( <form> {/* fields */} <button type="button" onClick={handleCancel}>Cancel</button> <button type="submit">Save</button> </form> ); }
Array Fields (Dynamic)
import { useFieldArray, useForm } from 'react-hook-form'; function TeamMembersForm() { const { control, register } = useForm({ defaultValues: { members: [{ name: '', email: '' }] } }); const { fields, append, remove } = useFieldArray({ control, name: 'members' }); return ( <form> {fields.map((field, index) => ( <div key={field.id}> <input {...register(`members.${index}.name`)} placeholder="Name" /> <input {...register(`members.${index}.email`)} placeholder="Email" /> <button type="button" onClick={() => remove(index)}>Remove</button> </div> ))} <button type="button" onClick={() => append({ name: '', email: '' })}> Add Member </button> </form> ); }
TanStack Form Patterns
Basic Form
import { useForm } from '@tanstack/react-form'; import { zodValidator } from '@tanstack/zod-form-adapter'; import { loginSchema } from './schemas'; function LoginForm() { const form = useForm({ defaultValues: { email: '', password: '' }, onSubmit: async ({ value }) => { await login(value); }, validatorAdapter: zodValidator() }); return ( <form onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }} > <form.Field name="email" validators={{ onBlur: loginSchema.shape.email }} > {(field) => ( <div className="form-field"> <label htmlFor="email">Email</label> <input id="email" type="email" autoComplete="email" value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} /> {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( <span role="alert">{field.state.meta.errors[0]}</span> )} </div> )} </form.Field> <form.Field name="password" validators={{ onBlur: loginSchema.shape.password }} > {(field) => ( <div className="form-field"> <label htmlFor="password">Password</label> <input id="password" type="password" autoComplete="current-password" value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} /> {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( <span role="alert">{field.state.meta.errors[0]}</span> )} </div> )} </form.Field> <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}> {([canSubmit, isSubmitting]) => ( <button type="submit" disabled={!canSubmit}> {isSubmitting ? 'Signing in...' : 'Sign in'} </button> )} </form.Subscribe> </form> ); }
Async Validation with Debouncing
<form.Field name="username" validators={{ onBlur: loginSchema.shape.username, onChangeAsyncDebounceMs: 500, onChangeAsync: async ({ value }) => { const response = await fetch(`/api/check-username?u=${value}`); const { available } = await response.json(); return available ? undefined : 'Username is taken'; } }} > {(field) => ( <div> <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} /> {field.state.meta.isValidating && <span>Checking...</span>} {field.state.meta.errors[0] && <span>{field.state.meta.errors[0]}</span>} </div> )} </form.Field>
Linked Fields (Password Confirmation)
<form.Field name="confirmPassword" validators={{ onChangeListenTo: ['password'], onChange: ({ value, fieldApi }) => { const password = fieldApi.form.getFieldValue('password'); return value !== password ? 'Passwords do not match' : undefined; } }} > {(field) => ( <input type="password" value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> )} </form.Field>
Common Patterns
Form with Server Errors
function LoginForm() { const [serverError, setServerError] = useState<string | null>(null); const { handleSubmit, setError } = useForm<LoginFormData>({ resolver: zodResolver(loginSchema) }); const onSubmit = async (data: LoginFormData) => { try { setServerError(null); await login(data); } catch (error) { if (error.field) { // Field-specific error setError(error.field, { message: error.message }); } else { // General error setServerError(error.message); } } }; return ( <form onSubmit={handleSubmit(onSubmit)}> {serverError && ( <div role="alert" className="form-error"> {serverError} </div> )} {/* fields */} </form> ); }
Loading State
function ContactForm() { const { handleSubmit, formState: { isSubmitting } } = useForm(); return ( <form onSubmit={handleSubmit(onSubmit)} aria-busy={isSubmitting}> {/* fields */} <button type="submit" disabled={isSubmitting}> {isSubmitting ? ( <> <Spinner aria-hidden="true" /> <span className="sr-only">Sending message...</span> Sending... </> ) : ( 'Send Message' )} </button> </form> ); }
Focus First Error
import { useForm } from 'react-hook-form'; import { useRef, useEffect } from 'react'; function MyForm() { const formRef = useRef<HTMLFormElement>(null); const { handleSubmit, formState: { errors, isSubmitSuccessful } } = useForm(); // Focus first error after failed submit useEffect(() => { if (Object.keys(errors).length > 0) { const firstError = formRef.current?.querySelector('[aria-invalid="true"]'); (firstError as HTMLElement)?.focus(); } }, [errors]); return <form ref={formRef}>{/* fields */}</form>; }
File Structure
form-react/ ├── SKILL.md ├── references/ │ ├── rhf-patterns.md # React Hook Form deep-dive │ ├── tanstack-patterns.md # TanStack Form deep-dive │ └── migration-guide.md # Formik → RHF migration └── scripts/ ├── rhf-form-builder.tsx # RHF form patterns ├── tanstack-form-builder.tsx # TanStack patterns ├── form-field.tsx # Reusable field component ├── use-form-field.ts # Custom hook └── schemas/ # Shared with form-validation ├── auth.ts ├── profile.ts └── payment.ts
Integration with Other Skills
// Combine: form-react + form-validation + form-accessibility + form-security import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { loginSchema } from 'form-validation/schemas/auth'; import { FormField } from 'form-accessibility/aria-form-wrapper'; import { AUTOCOMPLETE } from 'form-security/autocomplete-config'; function LoginForm({ onSubmit }) { const methods = useForm({ resolver: zodResolver(loginSchema), mode: 'onBlur' }); return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <FormField label="Email" name="email" error={methods.formState.errors.email?.message} touched={methods.formState.touchedFields.email} required > <input type="email" autoComplete={AUTOCOMPLETE.email} {...methods.register('email')} /> </FormField> {/* ... */} </form> </FormProvider> ); }
Reference
— Complete React Hook Form patternsreferences/rhf-patterns.md
— TanStack Form patternsreferences/tanstack-patterns.md
— Migrating from Formikreferences/migration-guide.md