react-hook-form
SKILL.md
React Hook Form Patterns
Setup
npm install react-hook-form @hookform/resolvers zod
Basic Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
});
type FormData = z.infer<typeof schema>;
function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "" },
});
async function onSubmit(data: FormData) {
await api.createContact(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} />
{errors.name && <p role="alert">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</form>
);
}
Always provide defaultValues — it enables proper reset behavior and avoids uncontrolled-to-controlled warnings.
Validation Modes
useForm({
resolver: zodResolver(schema),
mode: "onBlur", // validate on blur (good default for most forms)
// mode: "onChange", // real-time validation (can be noisy)
// mode: "onSubmit", // validate only on submit (default)
// mode: "onTouched", // validate on blur, then on change after first blur
// mode: "all", // validate on blur and change
});
onBluris the best default — validates after the user leaves a field without being intrusive.onSubmitfor simple forms where inline validation isn't needed.onChangefor search/filter inputs where immediate feedback matters.
Controlled Components
For UI libraries (date pickers, selects, rich editors) that don't support ref:
import { Controller } from "react-hook-form";
<Controller
name="role"
control={control}
render={({ field, fieldState }) => (
<Select value={field.value} onChange={field.onChange} onBlur={field.onBlur} error={fieldState.error?.message}>
<Option value="admin">Admin</Option>
<Option value="member">Member</Option>
</Select>
)}
/>;
Field Arrays
Dynamic lists of fields (line items, addresses, tags):
import { useFieldArray } from "react-hook-form";
const schema = z.object({
items: z
.array(
z.object({
name: z.string().min(1),
quantity: z.coerce.number().min(1),
}),
)
.min(1, "At least one item required"),
});
function OrderForm() {
const { control, register, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { items: [{ name: "", quantity: 1 }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.quantity`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", quantity: 1 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}
Always use field.id as the key, not the array index.
Multi-Step Forms
const Step1Schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const Step2Schema = z.object({
address: z.string().min(1),
city: z.string().min(1),
});
const FullSchema = Step1Schema.merge(Step2Schema);
type FullFormData = z.infer<typeof FullSchema>;
function MultiStepForm() {
const [step, setStep] = useState(1);
const form = useForm<FullFormData>({
resolver: zodResolver(FullSchema),
defaultValues: { name: "", email: "", address: "", city: "" },
mode: "onBlur",
});
async function handleNext() {
const fieldsToValidate = step === 1 ? (["name", "email"] as const) : (["address", "city"] as const);
const valid = await form.trigger(fieldsToValidate);
if (valid) setStep((s) => s + 1);
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <Step1Fields register={form.register} errors={form.formState.errors} />}
{step === 2 && <Step2Fields register={form.register} errors={form.formState.errors} />}
{step < 2 ? (
<button type="button" onClick={handleNext}>
Next
</button>
) : (
<button type="submit">Submit</button>
)}
</form>
);
}
Use trigger() with specific field names to validate only the current step.
Watching Values
React to field changes without re-rendering the entire form:
const watchedRole = form.watch("role");
// For side effects
useEffect(() => {
if (watchedRole === "admin") {
form.setValue("permissions", ["read", "write", "delete"]);
}
}, [watchedRole]);
For performance-critical cases, use useWatch from a child component to isolate re-renders.
Server Errors
Map server-side validation errors to specific fields:
async function onSubmit(data: FormData) {
try {
await api.createUser(data);
} catch (error) {
if (error instanceof ValidationError) {
error.fields.forEach(({ field, message }) => {
form.setError(field as keyof FormData, { message });
});
} else {
form.setError("root", { message: "Something went wrong" });
}
}
}
// Display root-level errors
{
form.formState.errors.root && <p role="alert">{form.formState.errors.root.message}</p>;
}
Form Wrapper Component
Create a reusable form wrapper for consistent error display and layout:
interface FormFieldProps {
label: string;
name: string;
error?: string;
children: React.ReactNode;
}
function FormField({ label, name, error, children }: FormFieldProps) {
return (
<div>
<label htmlFor={name}>{label}</label>
{children}
{error && <p role="alert">{error}</p>}
</div>
);
}
Guidelines
- Always use Zod (or another resolver) for validation — avoid inline
registervalidation rules for anything beyond trivial forms. - Always provide
defaultValuestouseForm. - Use
registerfor native HTML inputs. UseControllerfor custom components. - Avoid
watch()in the parent form component for values only needed in a child — useuseWatchin the child instead. - Use
form.reset()after successful submission, not manual state clearing. - Disable the submit button with
isSubmittingto prevent double submissions.
Weekly Installs
5
Repository
grahamcrackers/skillsFirst Seen
Feb 25, 2026
Security Audits
Installed on
gemini-cli5
github-copilot5
codex5
kimi-cli5
cursor5
opencode5