@repo/use-form is a thin wrapper around react-hook-form with built-in Zod validation. It exports a useForm hook that accepts a schema prop (Zod schema) and automatically wires up zodResolver. It also re-exports commonly used react-hook-form utilities: useController, useFieldArray, useFormState, useWatch, and key types.
flowchart LR
useForm["@repo/use-form"]
ts["@repo/typescript-config"]
vitest["@repo/vitest-config"]
useForm -.-> ts
useForm -.-> vitestimport { z } from "zod";
import { useForm } from "@repo/use-form";
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
schema,
defaultValues: { name: "", email: "" },
});
const onSubmit = handleSubmit((data) => {
// data is typed as { name: string; email: string }
});
return (
<form onSubmit={onSubmit}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
You can pass any react-hook-form option except resolver (which is always wired to zodResolver from schema):
const form = useForm({
schema,
defaultValues: { name: "", email: "" },
mode: "onTouched", // validate on blur, then on change
reValidateMode: "onChange",
});
The hook supports schemas with .transform() or z.coerce. The form values use z.input<Schema> (what the user types), while handleSubmit receives z.output<Schema> (the transformed result):
const schema = z.object({
age: z.coerce.number().min(0), // form holds a string, submit receives a number
tags: z.string().transform((s) => s.split(",")),
});
Use useFieldArray for add/remove/reorder patterns on array fields:
import { useForm, useFieldArray } from "@repo/use-form";
const schema = z.object({
questions: z.array(z.object({
text: z.string().min(1),
type: z.enum(["text", "number", "boolean"]),
})),
});
function QuestionsEditor() {
const { control, register } = useForm({
schema,
defaultValues: { questions: [{ text: "", type: "text" }] },
});
const { fields, append, remove, move } = useFieldArray({ control, name: "questions" });
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`questions.${index}.text`)} />
<button onClick={() => remove(index)}>Remove</button>
{index > 0 && <button onClick={() => move(index, index - 1)}>Move up</button>}
</div>
))}
<button onClick={() => append({ text: "", type: "text" })}>Add question</button>
</div>
);
}
useWatch subscribes to field changes without re-rendering the entire form. Useful for conditional UI or derived state:
import { useForm, useWatch } from "@repo/use-form";
function DownloadOptions() {
const { control, trigger } = useForm({ schema, defaultValues });
const includeSummary = useWatch({ control, name: "includeSummary" });
const includeTranscript = useWatch({ control, name: "includeTranscript" });
// Re-trigger cross-field validation when either changes
useEffect(() => {
trigger("includeSummary");
}, [includeSummary, includeTranscript, trigger]);
return (
<div>
{includeSummary && <p>Summary will be included in the download.</p>}
</div>
);
}
useController connects react-hook-form to components that don't accept register (e.g. custom comboboxes, date pickers):
import { useController } from "@repo/use-form";
import type { FieldValues, FieldPath } from "@repo/use-form/internals";
function PatientCombobox<TFieldValues extends FieldValues>({
name,
control,
}: {
name: FieldPath<TFieldValues>;
control: Control<TFieldValues>;
}) {
const { field } = useController({ control, name });
return (
<Combobox
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
/>
);
}
The Controller component (from @repo/use-form/internals) provides a render-prop API for cases where you need full control over the render:
import { Controller } from "@repo/use-form/internals";
function TemplateTitle({ control, errors }) {
return (
<Controller
name="title"
control={control}
render={({ field }) => (
<Editable.Root
value={field.value || ""}
onValueChange={({ value }) => field.onChange(value)}
onBlur={field.onBlur}
>
<Editable.Preview />
<Editable.Input />
</Editable.Root>
)}
/>
);
}
When a component needs both an external ref and react-hook-form's field ref, use composeRef from internals:
import { composeRef } from "@repo/use-form/internals";
// Inside a Controller render prop:
render={({ field }) => (
<input ref={composeRef(externalRef, field)} {...field} />
)}
| Entry point | Hooks / Components | Types |
|---|---|---|
@repo/use-form |
useForm, useController, useFieldArray, useFormState, useWatch |
Control, FieldErrors, UseFieldArrayReturn, UseFormRegister, UseFormReturn, UseFormSetFocus, UseFormSetValue, UseFormProps |
@repo/use-form/internals |
Controller, composeRef |
ControllerProps, FieldPath, FieldPathByValue, FieldValues |
| Script | Description |
|---|---|
lint |
Run Biome check |
check-types |
Typecheck with tsc --noEmit |
test |
Run Vitest with coverage |
test:watch |
Run Vitest in watch mode |