React Hook Form
@beignet/react-hook-form creates typed React Hook Form options from a contract body schema. Use it when a form submits to a contract and should reuse the same validation rules on the client.
bun add @beignet/react-hook-form react-hook-form @hookform/resolversReact Hook Form only owns the request body fields. Path params, query params, required headers, idempotency keys, and auth context still belong to the endpoint call.
Setup
import { createReactHookForm } from "@beignet/react-hook-form";
import { createTodo } from "@/features/todos/contracts";
const rhf = createReactHookForm();
const createTodoForm = rhf(createTodo);Bind the contract builder directly with rhf(contract). Use
contract.config only for integration code that cannot accept the builder.
Basic form
function CreateTodoForm() {
const form = createTodoForm.useForm({
defaultValues: { title: "", completed: false },
});
const onSubmit = form.handleSubmit(async (data) => {
await createTodoEndpoint.call({ body: data });
});
return (
<form onSubmit={onSubmit}>
<input {...form.register("title")} />
{form.formState.errors.title && (
<span>{form.formState.errors.title.message}</span>
)}
<button type="submit" disabled={form.formState.isSubmitting}>
Create
</button>
</form>
);
}Validation runs automatically using your contract's body schema. Field names, values, and error messages are inferred from the contract.
Input and output types
Form types follow React Hook Form's input/output split. Live field values —
register, watch, setValue, getValues, and defaultValues — use the
body schema's input: what the user edits before validation runs. handleSubmit
callbacks receive the schema's output: the parsed values after coercion,
transforms, and defaults run. For plain schemas the two are identical.
const createPayment = payments
.post("/api/payments")
.body(
z.object({
amount: z.string().transform(Number),
note: z.string().optional(),
}),
)
.responses({ 201: paymentSchema });
const form = rhf(createPayment).useForm({
defaultValues: { amount: "" }, // input: string
});
form.watch("amount"); // string (input)
form.handleSubmit((values) => {
values.amount; // number (output)
});The typed client posts the schema input — the server validates and transforms
the body when it receives the request. Parsed output is still valid input for
plain, defaulted, and coerced schemas, so passing handleSubmit values to the
endpoint call or mutation keeps working for those. When a transform changes a
field's type, the parsed output no longer matches the contract body and
TypeScript rejects it. Send the raw field values instead — validation has
already passed by the time the submit handler runs:
const onSubmit = form.handleSubmit(() => {
mutation.mutate({ body: form.getValues() });
});With React Query
import { rootFormError } from "@beignet/react-hook-form";
function CreateTodoForm() {
const form = createTodoForm.useForm({
defaultValues: { title: "", completed: false },
});
const mutation = useMutation(
rq(createTodo).mutationOptions({
onSuccess: () => form.reset(),
onError: (error) => {
form.setError("root", rootFormError(error, "Could not create the todo."));
},
}),
);
const onSubmit = form.handleSubmit((data) => {
form.clearErrors("root");
mutation.mutate({ body: data });
});
return (
<form onSubmit={onSubmit}>
<input {...form.register("title")} />
{form.formState.errors.title && (
<span>{form.formState.errors.title.message}</span>
)}
{form.formState.errors.root && (
<span>{form.formState.errors.root.message}</span>
)}
<button type="submit" disabled={mutation.isPending}>
Create
</button>
</form>
);
}rootFormError(error, fallback, overrides?) wraps contractErrorMessage
from @beignet/core/client into the form.setError("root", ...) shape:
non-contract errors get the fallback copy, and catalog codes can override
copy per form. Map server failures into form.setError("root", ...) for
form-level failures, or into a specific field when the server response
explicitly identifies one. See Errors for the
underlying message mapping.
Form options
Get raw form options if you want to call useForm yourself.
import { useForm } from "react-hook-form";
const form = useForm(
createTodoForm.formOptions({
defaultValues: { title: "" },
mode: "onBlur",
}),
);Disable automatic validation
Set resolverEnabled to false when you want React Hook Form typing without the schema resolver.
const form = createTodoForm.useForm({
resolverEnabled: false,
});React Hook Form controls when the resolver runs. With the default React Hook
Form settings, Beignet's generated resolver validates before submit and then
revalidates changed fields after a failed submit. Pass normal React Hook Form
options such as mode: "onBlur" or reValidateMode: "onChange" when a form
needs different timing.