TanStack Form

@beignet/react-form creates typed TanStack 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-form @tanstack/react-form@^1.33.5

TanStack Form 1.33.5 or newer in the 1.x line is required for retries after server errors.

TanStack Form accepts Standard Schema validators directly, so no resolver package is needed. The adapter 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 starter generates React Hook Form code. In an app that depends on @beignet/react-form, both beignet make feature <name> --with ui and --recipe full-slice stop with guidance. Generate the feature without those options and add a TanStack Form component using the pattern below. Choose one form library for new forms; existing forms can keep their adapter while you migrate them.

Setup

These examples extend the Todos feature from Quickstart. Add rf to client/forms.ts, preserving any existing exports. In an unchanged starter, the complete file becomes:

// client/forms.ts
import { createReactForm } from "@beignet/react-form";
import { createReactHookForm } from "@beignet/react-hook-form";

export const rhf = createReactHookForm();
export const rf = createReactForm();

Keep rhf while existing components such as features/todos/components/todo-app.tsx still import it. Once those components use TanStack Form, remove the rhf export and any unused React Hook Form dependencies. In an app that only uses TanStack Form, omit the createReactHookForm import and rhf export.

Then add the feature binding:

// features/todos/client/forms.ts
import { rf } from "@/client/forms";
import { createTodo } from "@/features/todos/contracts";

export const createTodoForm = rf(createTodo);

Bind the contract directly with rf(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 { fieldErrorMessages, serverErrorMap } from "@beignet/react-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 mutation = useMutation(
    rq(createTodo).mutationOptions({
      invalidates: () => [rq(listTodos).contractFilter()],
    }),
  );
  const form = createTodoForm.useForm({
    defaultValues: { title: "" },
    onSubmit: async ({ value, formApi }) => {
      try {
        await mutation.mutateAsync({ body: value });
        formApi.reset();
      } catch (error) {
        formApi.setErrorMap(
          serverErrorMap(error, "Could not create the todo."),
        );
      }
    },
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void form.handleSubmit();
      }}
    >
      <form.Field name="title">
        {(field) => {
          const messages = fieldErrorMessages(field.state.meta.errors);
          return (
            <>
              <label htmlFor={titleId}>Title</label>
              <input
                id={titleId}
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(event) => field.handleChange(event.target.value)}
                readOnly={mutation.isPending}
                aria-invalid={messages.length > 0}
                aria-describedby={
                  messages.length > 0 ? `${titleId}-error` : undefined
                }
              />
              {messages.length > 0 && (
                <p id={`${titleId}-error`} role="alert">
                  {messages.join(" ")}
                </p>
              )}
            </>
          );
        }}
      </form.Field>
      <form.Subscribe selector={(state) => state.errorMap.onServer}>
        {(message) => (message ? <p role="alert">{message}</p> : null)}
      </form.Subscribe>
      <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 and the issue renders under the field. 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 the server 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.

Await mutation.mutateAsync(...) inside onSubmit so form.state.isSubmitting covers the request, and catch failures there: form.handleSubmit() rethrows anything onSubmit throws.

serverErrorMap(error, fallback, overrides?) wraps contractErrorMessage from @beignet/core/client into the { onServer } shape that formApi.setErrorMap(...) accepts. Non-contract errors get the fallback copy, and catalog codes can override copy per form. Read the message from form.state.errorMap.onServer. TanStack Form clears it when the next submit validates cleanly. See Errors.

Values and validation

Form values use the body schema's input type. TanStack Form validates values but never transforms them, so defaultValues, field.state.value, and the value passed to onSubmit are all what the user edits. That is also what the typed client posts, so mutation.mutate({ body: value }) typechecks even when the schema transform changes a field's type.

This example uses a separate payment contract to demonstrate a transform; 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 = rf(createPayment).useForm({
  defaultValues: { amount: "" }, // input: string
  onSubmit: ({ value }) => {
    value.amount; // string (input); the server parses it to a number
  },
});

Choosing when the schema runs

TanStack Form validates per slot rather than by mode. The adapter installs the body schema in one async slot chosen by validateOn, which defaults to "submit":

const form = createTodoForm.useForm({
  validateOn: "change", // "submit" | "change" | "blur" | "dynamic" | false
  defaultValues: { title: "" },
});

"dynamic" validates on submit and revalidates on change after the first submission. It runs through TanStack Form's validationLogic, which the adapter defaults to revalidateLogic(); pass your own strategy to change the timing:

import { revalidateLogic } from "@tanstack/react-form";

const form = createTodoForm.useForm({
  validateOn: "dynamic",
  validationLogic: revalidateLogic({ mode: "blur" }),
  defaultValues: { title: "" },
});

The schema uses onSubmitAsync, onChangeAsync, onBlurAsync, or onDynamicAsync, so synchronous schemas and async refinements both work. TanStack Form awaits validation before calling onSubmit; form values remain the schema input. Error-map keys still use the event name, such as form.state.errorMap.onSubmit.

Your own validators compose with the schema. A validator in the selected async slot replaces it; for example, validators.onSubmitAsync overrides the schema with the default validateOn: "submit". Synchronous validators such as onSubmit run first, and TanStack Form skips async validation if they fail unless you set asyncAlways: true. Validators in other slots remain intact.

Form options

Get raw TanStack Form options when you want to call useForm yourself, or spread them into an app-level useAppForm from createFormHook or into withForm:

import { useForm } from "@tanstack/react-form";

const form = useForm(
  createTodoForm.formOptions({
    defaultValues: { title: "" },
    validateOn: "blur",
  }),
);

Disable automatic validation

Set validateOn to false when you want TanStack Form typing without the schema validator, for example in partial or multi-step flows. Keep final validation in use cases.

const form = createTodoForm.useForm({
  validateOn: false,
});