Circle V2 API Docs
    Preparing search index...

    Module @repo/use-form

    @repo/use-form

    @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.

    %%{init:{"theme":"dark"}}%% flowchart LR useForm["@repo/use-form"] ts["@repo/typescript-config"] vitest["@repo/vitest-config"] useForm -.-> ts useForm -.-> vitest
    %%{init:{"theme":"default"}}%% flowchart LR useForm["@repo/use-form"] ts["@repo/typescript-config"] vitest["@repo/vitest-config"] useForm -.-> ts useForm -.-> vitest
    flowchart LR
      useForm["@repo/use-form"]
      ts["@repo/typescript-config"]
      vitest["@repo/vitest-config"]
      useForm -.-> ts
      useForm -.-> vitest
    import { 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

    Modules

    internals