@beignet/react-form
v0.0.56
Published
TanStack Form integration for Beignet with Standard Schema support
Downloads
426
Maintainers
Readme
@beignet/react-form
Runtime: Beignet requires Node.js 22.12 or newer. Bun is optional.
TanStack Form integration for Beignet
[!CAUTION] Beignet is experimental alpha software. The
0.0.xpackage line is for early evaluation, and APIs may change between releases while the framework settles.
This package turns a contract body schema into typed TanStack Form options. TanStack Form accepts Standard Schema validators natively, so the contract's Zod, Valibot, or ArkType body schema becomes the form validator without a resolver package.
If your app already uses React Hook Form, use
@beignet/react-hook-form instead.
Both adapters bind the same contracts. To switch an existing app, follow the
TanStack Form setup guide and retain
its existing form helpers until their components have been migrated.
Installation
npm install @beignet/react-form @beignet/core @tanstack/react-form@^1.33.5 reactTanStack Form 1.33.5 or newer in the 1.x line is required so a failed submit can be retried after setting a server error.
TypeScript requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Agent skills
This package ships a TanStack Intent skill for coding agents:
@beignet/react-form#forms. Load it when adding contract-backed forms,
TanStack Form setup, validateOn slots, serverErrorMap, React Query
mutations, form.Field rendering, feature component boundaries, or
field/server error mapping in a Beignet app.
Usage
Basic form
These examples use the Todos contracts from the Beignet starter. Create accepts
title; completion belongs to the update contract. For a working form with
verification steps, follow the form guide.
"use client";
import { createReactForm, fieldErrorMessages } from "@beignet/react-form";
import { createTodo } from "@/features/todos/contracts";
const rf = createReactForm();
const createTodoForm = rf(createTodo);
function CreateTodoForm() {
const form = createTodoForm.useForm({
defaultValues: { title: "" },
onSubmit: ({ value }) => {
// value is the schema input: { title: string }
console.log("Creating todo:", value);
},
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<form.Field name="title">
{(field) => (
<>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="What needs to be done?"
/>
{fieldErrorMessages(field.state.meta.errors).map((message) => (
<p className="error" key={message}>
{message}
</p>
))}
</>
)}
</form.Field>
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button type="submit" disabled={isSubmitting}>
Create Todo
</button>
)}
</form.Subscribe>
</form>
);
}With React Query mutation
"use client";
import {
createReactForm,
fieldErrorMessages,
serverErrorMap,
} from "@beignet/react-form";
import { useMutation } from "@tanstack/react-query";
import { rq } from "@/client";
import { createTodo, listTodos } from "@/features/todos/contracts";
const rf = createReactForm();
const createTodoForm = rf(createTodo);
function CreateTodoForm() {
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) => (
<>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="Title"
readOnly={mutation.isPending}
/>
{fieldErrorMessages(field.state.meta.errors).map((message) => (
<p key={message}>{message}</p>
))}
</>
)}
</form.Field>
<form.Subscribe selector={(state) => state.errorMap.onServer}>
{(message) => (message ? <p>{message}</p> : null)}
</form.Subscribe>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create"}
</button>
</form>
);
}TanStack Form only owns request body fields. Pass path params, query params,
headers, and auth-derived values to the endpoint call or mutation variables.
Submit failures come back as Beignet client errors, so map route-owned catalog
errors, framework validation errors, network failures, and contract drift
through serverErrorMap(...) and formApi.setErrorMap(...). TanStack Form
clears the previous server error when the next submit validates cleanly, so a
retry never shows stale copy.
Choosing when the schema runs
TanStack Form validates per slot. 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: "" },
});Use "dynamic" to validate on submit and revalidate 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: "" },
});Set validateOn: false to keep the form typing without the schema, for
example in partial or multi-step flows where the use case still validates.
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.
Composing with useAppForm and withForm
formOptions(...) returns plain TanStack Form options, so it spreads into
useForm from @tanstack/react-form, an app-level useAppForm created with
createFormHook, or withForm:
import { useForm } from "@tanstack/react-form";
const form = useForm(
createTodoForm.formOptions({
defaultValues: { title: "" },
validateOn: "blur",
}),
);Using contract config directly
You can pass either a contract builder or its config:
const { useForm } = rf(createTodo);
// Or using the contract config
const { useForm } = rf(createTodo.config);API reference
createReactForm()
Creates a TanStack Form adapter factory.
const rf = createReactForm();rf(contract)
Creates a TanStack Form adapter for a contract whose body schema input is an object. TypeScript rejects contracts with scalar or array body inputs because they do not define named form fields. The schema output may still transform to any value.
const adapter = rf(createTodo);adapter.formOptions(options?)
Returns TanStack Form options with the contract body schema installed as a Standard Schema validator.
const options = adapter.formOptions({
defaultValues: { title: "" },
validateOn: "blur",
});validateOn defaults to "submit"; it also accepts "change", "blur",
"dynamic", or false. Pass other TanStack Form options such as validators,
listeners, onSubmit, and onSubmitMeta in the same object.
adapter.useForm(options?)
Calls useForm from @tanstack/react-form with adapter.formOptions(options).
serverErrorMap(error, fallback, overrides?)
Maps a failed endpoint call or mutation error to the { onServer } shape that
form.setErrorMap(...) accepts. Wraps contractErrorMessage from
@beignet/core/client: non-contract errors return the fallback copy,
client-side input validation failures return a generic "check the highlighted
fields" message, and catalog codes can override copy per form.
formApi.setErrorMap(
serverErrorMap(error, "Could not update profile.", {
HANDLE_UNAVAILABLE: "That handle is already taken.",
}),
);fieldErrorMessages(errors)
Flattens a TanStack Form error list such as field.state.meta.errors into
strings. Standard Schema validators produce issue objects, function validators
usually return strings, and empty slots are undefined; all three are handled.
Type inference
Form values come from the contract body schema input. TanStack Form
validates values but never transforms them, so defaultValues, field state,
and the value passed to onSubmit all share the input type:
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 typed client posts the schema input and the server transforms it, so the
onSubmit value is always valid as the contract body for
mutation.mutate({ body: value }), transforms included.
The schema input must be object-shaped. Keep lists and scalar values inside named fields rather than making the request body itself an array or scalar:
const replaceTags = todos
.put("/api/todos/:id/tags")
.body(z.object({ tags: z.array(z.string()) }))
.responses({ 200: TodoSchema });
const form = rf(replaceTags).useForm({
defaultValues: { tags: [] },
});Field names are checked against the bound contract. For the starter's create
contract, title is the only field; description would be a type error:
function TodoTitleField() {
const form = rf(createTodo).useForm({ defaultValues: { title: "" } });
return (
<form.Field name="title">
{(field) => (
<label>
Title
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
</label>
)}
</form.Field>
);
}Error state
Field issues appear on the matching form.Field in field.state.meta.errors.
Form-level results appear in form.state.errorMap under the slot the schema
runs in, keyed by field path. Server errors live in form.state.errorMap.onServer
as a string.
<form.Subscribe selector={(state) => state.errorMap.onServer}>
{(message) => (message ? <p role="alert">{message}</p> : null)}
</form.Subscribe>Related packages
@beignet/core/contracts- Core contract definitions@beignet/react-query- TanStack Query integration@beignet/react-hook-form- React Hook Form integration@beignet/core/client- HTTP client
License
MIT
