Beignet API reference
    Preparing search index...

    Module @beignet/react-form

    @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.x package 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.

    npm install @beignet/react-form @beignet/core @tanstack/react-form@^1.33.5 react
    

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

    This package requires TypeScript 5.0 or higher for proper type inference.

    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.

    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>
    );
    }
    "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.

    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.

    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",
    }),
    );

    You can pass either a contract builder or its config:

    const { useForm } = rf(createTodo);

    // Or using the contract config
    const { useForm } = rf(createTodo.config);

    Creates a TanStack Form adapter factory.

    const rf = createReactForm();
    

    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);
    

    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.

    Calls useForm from @tanstack/react-form with adapter.formOptions(options).

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

    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.

    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>
    );
    }

    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>

    MIT

    ReactFormContractAdapter
    ReactFormContractForm
    ReactFormContractFormOptions
    ReactFormContractOptions
    ServerErrorMap
    ServerErrorValidator
    ValidateOn
    createReactForm
    fieldErrorMessages
    serverErrorMap