Validation and submission
Use the definition schema as the single form-level validation contract. The
schema validates the complete React Hook Form (RHF) 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),
})
.refine(({ capacity, reservedSeats }) => reservedSeats <= capacity, {
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) => [
ui.field("title", {
control: "text",
label: "Event title",
required: true,
}),
ui.field("capacity", {
control: "text",
label: "Capacity",
required: true,
}),
ui.field("reservedSeats", {
control: "text",
label: "Reserved seats",
required: true,
}),
])The required property does not add a schema rule. See required is
UI-only.
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() })
.refine(({ invitationCode }) => invitationCode !== "EXPIRED", {
message: "This invitation has expired",
})Use kit.Form and kit.Fields for manual composition. A manually composed
kit.Form has no summary. 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 custom control receives focus only when it attaches input.ref. Read
Accessibility for the control contract.
A hidden field has no generated control, so kit.AutoForm shows its issue in
the summary. See Hidden fields keep their
values.
Submit input and output
The schema parses once, and the same parse gives the transformed output. See The schema parses once on submit.
onSubmit receives value, input, form, and submitter. See onSubmit
receives four values.
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>
)
}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.
The example keeps request failures in application state. See Server issues are not schema issues.
Translate validation messages
To translate the Zod, Valibot, or Yup messages, read Localization.