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 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 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() })
	.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:

  • value is the transformed FormOutput<Schema>.
  • input is the editable FormInput<Schema>.
  • form is the current form binding.
  • submitter is the captured submit control name and value, or null.

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.