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. The contract body input must be object-shaped; scalar and array body inputs should use the typed client directly instead of a form adapter. The schema output may still be any transformed submit value.
Setup
Use the Todos contracts from Quickstart. The starter
already installs the form packages, exports rhf from client/forms.ts, and
mounts the React Query provider. Sign in before submitting the form.
Add the feature binding:
// features/todos/client/forms.ts
import { rhf } from "@/client/forms";
import { createTodo } from "@/features/todos/contracts";
export const createTodoForm = rhf(createTodo);Outside the starter, create rhf once with createReactHookForm() from
@beignet/react-hook-form. Bind the contract directly with rhf(contract);
contract.config is only needed by integrations that cannot accept the builder.
Basic form
Add this component and render <CreateTodoForm /> on a signed-in page under
the existing Query Client provider:
// features/todos/components/create-todo-form.tsx
"use client";
import { rootFormError } from "@beignet/react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { useId } from "react";
import { rq } from "@/client";
import { createTodoForm } from "@/features/todos/client/forms";
import { createTodo, listTodos } from "@/features/todos/contracts";
export function CreateTodoForm() {
const titleId = useId();
const form = createTodoForm.useForm({ defaultValues: { title: "" } });
const mutation = useMutation(
rq(createTodo).mutationOptions({
invalidates: () => [rq(listTodos).contractFilter()],
onSuccess: () => form.reset(),
onError: (error) => {
form.setError("root", rootFormError(error, "Could not create the todo."));
},
}),
);
const onSubmit = form.handleSubmit((body) => {
form.clearErrors("root");
mutation.mutate({ body });
});
return (
<form onSubmit={onSubmit}>
<label htmlFor={titleId}>Title</label>
<input
id={titleId}
{...form.register("title")}
readOnly={mutation.isPending}
aria-invalid={!!form.formState.errors.title}
aria-describedby={
form.formState.errors.title ? `${titleId}-error` : undefined
}
/>
{form.formState.errors.title && (
<p id={`${titleId}-error`} role="alert">
{form.formState.errors.title.message}
</p>
)}
{form.formState.errors.root && (
<p role="alert">{form.formState.errors.root.message}</p>
)}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating…" : "Create"}
</button>
</form>
);
}Submit an empty title: the contract's body schema rejects it before a request is sent. Submit a valid title: the API saves it, the form resets, and mounted Todos queries refresh. If the request fails, the form keeps the title and shows an error. The fields stay read-only until the mutation and its invalidation work finish.
With React Query
invalidates above targets every cached call to listTodos. Active queries
refetch; inactive queries become stale.
See React Query mutations for the lifecycle and
cache-refresh behavior.
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. See Errors.
beignet make feature <name> --with ui generates form binding, mutation
handling, errors, success reset, and list invalidation. The full-slice
recipe includes the same UI addon. Build your first feature
walks through a complete Projects form with ownership checks and tests.
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.
This example uses a separate payment contract to demonstrate a transform that
changes a field's type; it does not replace the Todos schema. payments and
paymentSchema belong to that feature.
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() });
});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.