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.5TanStack 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,
});