Build forms with react-hook-form and Zod: separate schema file with full Zod API, register for native/shadcn inputs, setValue and watch for custom components (no Controller). Use when creating or editing forms, adding validation, or when the user mentions react-hook-form, zod, form schema, or form validation.
Apply this skill when:
.schema.ts file next to the form (or in a shared schemas folder). Define the full Zod schema there and export both the schema and the inferred type.register for native/shadcn inputs and setValue + watch for custom components.z.infer<typeof schema>; use the same schema with zodResolver(schema) in useForm..schema.ts{Feature}Form.schema.ts (or editStop.schema.ts) in the same feature folder as the form.z.object({ ... }) and use Zod’s full API:
z.string(), z.number(), z.boolean(), z.date(), z.enum(), z.array(), etc..min(), .max(), .email(), .url(), .optional(), .nullable().refine() / .superRefine() for cross-field or custom validation.transform() for coercion (use with care with form inputs; prefer parsing at submit if needed)// editStop.schema.ts
import { z } from "zod";
export const editStopSchema = z.object({
id: z.number().nullable(),
name: z.string().min(1, "Name is required"),
location: z.string().min(1, "Location is required"),
county_id: z.number().min(1, "County is required"),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
});
export type EditStopFormData = z.infer<typeof editStopSchema>;
.refine():const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
useForm)useForm from react-hook-form, zodResolver from @hookform/resolvers/zod, and the schema + type from the .schema.ts file.resolver: zodResolver(schema) and defaultValues that match the schema shape (e.g. from an entity or empty defaults).import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { editStopSchema, type EditStopFormData } from "./editStop.schema";
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm<EditStopFormData>({
resolver: zodResolver(editStopSchema),
defaultValues: {
id: stop?.id ?? null,
name: stop?.name ?? "",
location: stop?.location ?? "",
county_id: stop?.county_id ?? 0,
lat: stop?.lat ?? DEFAULT_LAT,
lng: stop?.lng ?? DEFAULT_LNG,
},
});
EditStopFormData type for handleSubmit(onSubmit) so submit receives typed data.register<input>, <select>, and shadcn components that forward ref and props (e.g. Input), use register.{...register("fieldName")} and add validation/options as needed (e.g. valueAsNumber for numbers).<Input
id="name"
{...register("name")}
placeholder="Enter name"
className={errors.name ? "border-destructive" : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && <FieldError errors={[{ message: errors.name.message }]} />}
valueAsNumber so the form value is a number (matches z.number()):<Input
id="lat"
type="number"
step="any"
{...register("lat", { valueAsNumber: true })}
placeholder="40.7128"
className={errors.lat ? "border-destructive" : undefined}
aria-invalid={!!errors.lat}
/>
register("field").onChange(e) inside your handler and then call setValue or other logic; keep the field registered.setValue and watchController. Use:
watch("fieldName") for the current value.setValue("fieldName", value) in the component’s onChange (optionally with shouldValidate: true).value={watch("fieldName")} and onChange={(value) => setValue("fieldName", value)} (or the equivalent for the component’s API).errors.fieldName?.message if the component supports an error prop.const countyValue = watch("county_id");
<SelectDropdown
options={counties ?? []}
value={countyValue}
onChange={(option) => setValue("county_id", option.id)}
displayKey="name"
valueKey="id"
label="County"
placeholder="Select a county"
error={errors.county_id?.message}
/>
setValue for each form field:const handlePlaceSelect = (place: PlaceData) => {
setLocationInput(place.address);
setValue("location", place.address);
setValue("lat", place.lat);
setValue("lng", place.lng);
};
<LocationAutocomplete
value={locationInput}
onChange={handlePlaceSelect}
label="Location"
error={errors.location?.message}
/>
const latValue = watch("lat");
const lngValue = watch("lng");
const handleMapMarkerDrag = (lat: number, lng: number) => {
setValue("lat", lat);
setValue("lng", lng);
};
<MapView
lat={latValue}
lng={lngValue}
onMarkerDragEnd={handleMapMarkerDrag}
/>
onSubmit={handleSubmit(onSubmit)} on the <form>. The onSubmit callback receives data typed as the schema’s inferred type.reset(defaultValues) with the same shape as defaultValues, then call the parent’s onCancel (or navigate away) so the form is closed or cleared.const handleCancel = () => {
reset({
id: null,
name: "",
location: "",
county_id: 0,
lat: DEFAULT_LAT,
lng: DEFAULT_LNG,
});
onCancel();
};
formState.errors; each field may have errors.fieldName?.message.Field + FieldLabel + FieldError (or equivalent). Pass errors.fieldName?.message to the component’s error prop when it supports it (e.g. SelectDropdown, LocationAutocomplete).className={errors.fieldName ? "border-destructive" : undefined} and aria-invalid={!!errors.fieldName} on inputs for accessibility and styling.| Input type | Binding | Example |
|---|---|---|
| Native & shadcn Input | register | {...register("name")}, register("lat", { valueAsNumber: true }) |
| Custom select/autocomplete/map | watch + setValue | value={watch("county_id")}, onChange={(v) => setValue("county_id", v)} |
.schema.ts, full Zod API, export schema + z.infer type.useForm with zodResolver(schema) and defaultValues; no Controller.