Validation and submission
Use the definition schema as the single form-level validation contract. The
schema validates the complete React Hook Form input. It also produces the value
that onSubmit receives.
Build the validation schema
Put field rules, cross-field rules, and transforms in the Standard Schema. This Zod schema validates reserved seats against the total capacity. It also transforms both seat values from strings to numbers.
"use client"
import type { FormInput, FormOutput } from "form-please"
import { nativeFormKit as kit } from "form-please/preset-native"
import { useState } from "react"
import { z } from "zod"
const bookingSchema = z
.object({
title: z.string().trim().min(3, "Enter at least three characters"),
capacity: z
.string()
.regex(/^\d+$/, "Enter a whole number")
.transform(Number),
reservedSeats: z
.string()
.regex(/^\d+$/, "Enter a whole number")
.transform(Number),
})
.superRefine(({ capacity, reservedSeats }, context) => {
if (reservedSeats > capacity) {
context.addIssue({
code: "custom",
message: "Reserved seats cannot exceed the capacity",
path: ["reservedSeats"],
})
}
})Give a cross-field issue the path of the field that the user must change. Form,
Please converts Standard Schema paths to RHF dot paths, such as
contacts.2.email.
Connect fields to the schema
Pass the schema to kit.defineForm. The generated fields edit the schema input.
const bookingDefinition = kit.defineForm(bookingSchema, {
ui: [
{
kind: "field",
path: "title",
control: "text",
label: "Event title",
required: true,
},
{
kind: "field",
path: "capacity",
control: "text",
label: "Capacity",
required: true,
},
{
kind: "field",
path: "reservedSeats",
control: "text",
label: "Reserved seats",
required: true,
},
],
})The required property changes the rendered control and its accessibility
state. It does not add a schema rule. The schema must reject a missing value.
kit.Form sets noValidate on the native form element. Browser constraints,
such as required and type="email", do not replace schema validation.
Understand validation timing
The resolver validates through React Hook Form once per validation run.
Form, Please configures this validation sequence:
| User action | Result |
|---|---|
| Edit before the first submit | The form does not run change validation. |
| Submit for the first time | React Hook Form validates the complete schema input. |
| Edit after the first submit attempt | React Hook Form validates on each change. |
| Submit valid input | The resolver returns transformed output and "Form, Please" calls onSubmit. |
The first submit attempt can be valid or invalid. After that attempt, each edit
runs the complete form-level schema. useForm can override mode,
reValidateMode, and delayError; it does not expose a replacement resolver.
Display issues
Standard Schema issues become public FormIssue values:
type FormIssue = {
readonly message: string
readonly path?: string
}A generated control receives all current issues in meta.errors. It receives
the issues that the UI can show in meta.displayErrors. The default field slot
shows issues after the field is touched or after a submit attempt.
kit.AutoForm also renders an error summary. The summary contains issues that
do not belong to a visible generated control. It also contains issues for
disabled generated controls.
Omit the issue path when the user cannot fix the issue in one field:
const invitationSchema = z
.object({ invitationCode: z.string() })
.superRefine(({ invitationCode }, context) => {
if (invitationCode === "EXPIRED") {
context.addIssue({
code: "custom",
message: "This invitation has expired",
})
}
})Use kit.Form and kit.Fields for manual composition. In that case, the
application must render its own summary for form-level and hidden-field issues.
Focus the first issue
After an invalid submit, RHF focuses its first registered invalid field, including an application-owned field. Focus order follows RHF registration order.
If no generated control can receive focus, kit.AutoForm focuses the first
error-summary item. A manually composed kit.Form has no automatic summary
fallback.
Hidden fields keep their values and remain part of schema validation. A hidden
field has no generated control, so its issue appears in the kit.AutoForm
summary. The schema must match the form's visibility rules when a hidden value
must not block submission.
Transformed output
The internal RHF resolver validates and produces FormOutput<Schema> in one
Standard Schema parse. Editable FormInput<Schema> remains in RHF state;
transformed output is passed only to a successful submit callback. No custom
validation cache is required.
Submit input and output
onSubmit receives four values:
valueis the transformedFormOutput<Schema>.inputis the editableFormInput<Schema>.formis the current form binding.submitteris the captured submit controlnameandvalue, ornull.
This example sends the transformed value to the server. It resets the editable
baseline with input after a successful request.
type SaveResult =
| { readonly ok: true }
| { readonly ok: false; readonly message: string }
async function saveBooking(
value: FormOutput<typeof bookingSchema>,
intent: string,
): Promise<SaveResult> {
const response = await fetch("/api/bookings", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ booking: value, intent }),
})
if (!response.ok) {
return { ok: false, message: "The booking could not be saved" }
}
return { ok: true }
}
const bookingDefaults = {
title: "",
capacity: "",
reservedSeats: "",
} satisfies FormInput<typeof bookingSchema>
export function BookingForm() {
const [submitError, setSubmitError] = useState<string>()
const form = kit.useForm(bookingDefinition, {
defaultValues: bookingDefaults,
onSubmit: async ({ value, input, form: binding, submitter }) => {
setSubmitError(undefined)
const result = await saveBooking(value, submitter?.value ?? "save")
if (!result.ok) {
setSubmitError(result.message)
return
}
binding.api.reset(input)
},
})
return (
<kit.AutoForm form={form}>
{submitError !== undefined && <p role="alert">{submitError}</p>}
<kit.Submit name="intent" value="save">
Save booking
</kit.Submit>
</kit.AutoForm>
)
}submitter is captured before validation. It is not a live DOM element and it
does not contain request metadata. onSubmit can return a promise.
form.api.formState.isSubmitting remains true until the promise settles.
kit.Submit is disabled while the form validates or submits.
Use FormSubmitDetails<Schema, Context> from form-please to type an extracted
submit handler without repeating the { value, input, form, submitter } object
shape.
Read Product workflows for multiple submit
actions and the complete snapshot contract.
Server validation is still required. A request failure is not a Standard Schema issue and does not appear in the form summary. Store and render request failures in application state, as the example does.
Set default Zod messages
"Form, Please" displays the messages that the Standard Schema returns. Configure Zod once, before form validation runs, when the application needs global defaults:
import { } from "zod"
.({
: () => {
if (. === "invalid_type") {
return "Enter a value of the correct type"
}
if (. === "too_small" && . === "string") {
return `Enter at least ${.} characters`
}
return
},
})Return undefined for issue types that must use the Zod default. A schema-level
message has priority over customError. Do not call z.config() during a
component render because it changes global Zod configuration.