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