Skip to content
Form, Please

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 actionResult
Edit before the first submitThe form does not run change validation.
Submit for the first timeReact Hook Form validates the complete schema input.
Edit after the first submit attemptReact Hook Form validates on each change.
Submit valid inputThe 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.

Next steps