Loading...
Loading...
Rules and patterns for building React forms with React Hook Form (RHF) and Zod validation. Use this skill whenever the user is creating, editing, or refactoring any React form — including login forms, registration flows, multi-step wizards, dynamic field arrays, or any input component wired to RHF. Also trigger when the user mentions `useForm`, `Controller`, `zodResolver`, `z.object`, schema validation, form state, `useFieldArray`, or `FormProvider`. Trigger even if they just ask "how do I validate this field" or "how do I handle server errors in a form" — this skill covers it all.
npx skill4agent add rugved1652/skills rhf-zoduseState.schema.tsControllerregisterz.infer<typeof schema>noValidatedefaultValueshandleSubmitsrc/features/<feature>/
schemas/login.schema.ts # Zod schema + z.infer export
forms/LoginForm.tsx # One form component
services/auth.service.ts # API/mutation logic
src/components/inputs/
ControlledInput.tsx # Reusable controlled wrappers (ControlledX.tsx)// login.schema.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().min(1, "Required").email("Enter a valid email"),
password: z.string().min(1, "Required").min(8, "Min 8 characters"),
});
export type LoginFormValues = z.infer<typeof loginSchema>;.min(1, "...").nonempty().refine().superRefine().extend().merge().transform()// Cross-field example
export const registerSchema = z
.object({
password: z.string().min(8, "Min 8 characters"),
confirmPassword: z.string().min(1, "Required"),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});const LoginForm = ({ onSubmit }) => {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<ControlledInput
name="email"
control={control}
label="Email"
error={errors.email}
/>
<ControlledInput
name="password"
control={control}
label="Password"
type="password"
error={errors.password}
/>
{errors.root && <div role="alert">{errors.root.message}</div>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in..." : "Sign In"}
</button>
</form>
);
};const ControlledInput = ({
name,
control,
label,
type = "text",
placeholder,
error,
...rest
}) => (
<div>
{label && <label htmlFor={name}>{label}</label>}
<Controller
name={name}
control={control}
render={({ field }) => (
<input
{...field}
id={name}
type={type}
placeholder={placeholder}
aria-invalid={!!error}
aria-describedby={error ? `${name}-error` : undefined}
{...rest}
/>
)}
/>
{error && (
<span id={`${name}-error`} role="alert">
{error.message}
</span>
)}
</div>
);<Controller
name="category"
control={control}
render={({ field }) => (
<CustomSelect
value={field.value}
onSelect={(v) => field.onChange(v)}
onBlur={field.onBlur}
/>
)}
/>errors.<field>.messagesetError("root", { message: "..." })errors.root?.messagearia-invalidaria-describedbyrole="alert"<label htmlFor>idshouldFocusError: trueconst onSubmit = (data) => {
mutate(data, {
onError: () => form.setError("root", { message: "Login failed" }),
});
};isSubmittingreset()onSubmitconst { fields, append, remove } = useFieldArray({ control, name: "items" });
// key={field.id} — never key on index
{
fields.map((field, i) => (
<div key={field.id}>
<ControlledInput
name={`items.${i}.description`}
control={control}
label="Desc"
/>
<button type="button" onClick={() => remove(i)}>
Remove
</button>
</div>
));
}
<button type="button" onClick={() => append({ description: "", qty: 1 })}>
Add
</button>;z.array(z.object({ description: z.string(), qty: z.number() }))// Parent
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate>
...
</form>
</FormProvider>;
// Deep child
const {
control,
formState: { errors },
} = useFormContext();useWatch({ name: "field" })watch()useMemo| ❌ Don't | ✅ Do |
|---|---|
| |
| |
| Inline schema in component | |
Manual | |
Validation in | Zod schema |
| Multiple forms in one file | One form per file |
| |
Missing | Always provide them |
Missing | Always add it |
No | |
| No submit guard | Disable during |
npm install react-hook-form zod @hookform/resolvers
# react-hook-form@^7.50 zod@^3.22 @hookform/resolvers@^3.3schemas/form-name.schema.tsz.inferforms/FormName.tsxuseFormzodResolverdefaultValuesControllernoValidatearia-*isSubmittingsetError("root")