--- title: "TanStack Form" description: "Create typed TanStack Form options from Beignet request body schemas with Standard Schema validation and server error mapping." url: "https://www.beignetjs.com/react-form" markdown_url: "https://www.beignetjs.com/markdown/react-form.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "ba3f49c103f3c4515e8ca14e07651fe5b83e2cec" source_modified: true --- # 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. ```bash 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](https://www.beignetjs.com/markdown/react-hook-form.md) code. In an app that depends on `@beignet/react-form`, both `beignet make feature --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](https://www.beignetjs.com/markdown/getting-started.md). Add `rf` to `client/forms.ts`, preserving any existing exports. In an unchanged starter, the complete file becomes: ```typescript // 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: ```typescript // 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 `` on a signed-in page under the existing Query Client provider: ```tsx // 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 (
{ event.preventDefault(); void form.handleSubmit(); }} > {(field) => { const messages = fieldErrorMessages(field.state.meta.errors); return ( <> field.handleChange(event.target.value)} readOnly={mutation.isPending} aria-invalid={messages.length > 0} aria-describedby={ messages.length > 0 ? `${titleId}-error` : undefined } /> {messages.length > 0 && ( )} ); }} state.errorMap.onServer}> {(message) => (message ?

{message}

: null)}
); } ``` 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](https://www.beignetjs.com/markdown/react-query.md#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](https://www.beignetjs.com/markdown/errors.md#map-errors-to-ui). ## 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. ```typescript 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"`: ```typescript 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: ```typescript 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`: ```typescript 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. ```typescript const form = createTodoForm.useForm({ validateOn: false, }); ```