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/resolvers

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