Skip to content
Form, Please

Production recipes

Use this page after the first generated form works. Choose the recipe that matches the problem in your application.

ProblemUse
Add product UI around generated fieldsOne binding with kit.Form and kit.Fields
Replace the document being editedA React key or form.api.reset(nextInput)
Discard editsA type="reset" button
Save a new baseline without replacing later editsform.api.resetDefaultValues(input)
Keep dependent values atomicmiddleware and form.update(recipe)
Apply a raw RHF update to multiple fieldsform.api.setValues(values)
Observe values without a form render subscriptionform.api.subscribe
Validate one wizard stepform.api.trigger and form.api.setFocus
Build a complete product workflowuseFormWorkflow as application code
Save parsed dataThe value argument in onSubmit
Show a request failureApplication state with role="alert"
Show a server field issueform.api.setError
Load one remote option listAsync field options
Coordinate request state with form UIForm context and fromResource
Lock the complete formThe disabled or readOnly option
Add a design-system controldefineControl and the full ControlProps contract

React Hook Form owns editable form state. The application owns requests, caches, persistence, navigation, permissions, and product workflow state.

Compose generated and custom UI

Create one form binding for one form. Put generated fields, application actions, RHF fields, and watched state inside the same kit.Form.

function ProfileForm() {
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: emptyProfile,
	})
	const email = useWatch({ control: form.api.control, name: "email" })
	const state = useFormState({ control: form.api.control })
	let dirtyState = <output>No changes</output>
	if (state.isDirty) dirtyState = <output>Unsaved changes</output>
 
	return (
		<nativeFormKit.Form form={form}>
			<nativeFormKit.Fields />
			<output>Current email: {email}</output>
			<button
				type="button"
				onClick={() => form.api.setValue("department", "Research")}
			>
				Use Research department
			</button>
			{dirtyState}
			<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
		</nativeFormKit.Form>
	)
}

Use RHF Controller, useController, or register for custom fields. Use useWatch and useFormState for derived UI. The unchanged methods on form.api, such as setValue, remain available for application actions.

Do not copy a form value into React state. Two editable copies can become inconsistent. Do not create a second form binding for another panel that edits the same document.

Use kit.AutoForm when the error summary and generated fields can render first. It renders custom children after the generated fields. Use kit.Form with kit.Fields when custom content needs another position or no error summary.

Load an edit-form baseline

Wait for the document before you mount the component that calls kit.useForm. Use the document ID as a React key when navigation can replace the document.

function ProfileScreen({ profile }: { readonly profile: Profile | undefined }) {
	if (profile === undefined) return <p>Loading…</p>
	return <ProfileEditor key={profile.id} profile={profile} />
}
 
function ProfileEditor({ profile }: { readonly profile: Profile }) {
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: profile,
	})
	return (
		<nativeFormKit.AutoForm form={form}>
			<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
		</nativeFormKit.AutoForm>
	)
}

The form definition and synchronous defaultValues are fixed for one component mount. Changing either prop does not replace current values.

Do not use this conditional update as a document navigation workflow. Remount the editor for a different document. Call form.api.reset(nextInput) only when the application intentionally replaces the current work.

Reset or discard changes

A reset without arguments restores the current baseline. A reset with a value installs that schema input as the next baseline.

function ResettableProfile({ profile }: { readonly profile: Profile }) {
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: profile,
		onSubmit: async ({ value, form }) => {
			const saved = await updateProfile(value)
			form.api.reset(saved)
		},
	})
	const state = useFormState({ control: form.api.control })
 
	return (
		<nativeFormKit.AutoForm form={form}>
			<button disabled={!state.isDirty} type="reset">
				Discard changes
			</button>
			<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
		</nativeFormKit.AutoForm>
	)
}

kit.Form handles the native reset event. It prevents the browser from resetting controls independently and calls form.api.reset().

Use these operations for different results:

OperationResult
form.api.reset()Restore the current baseline and clear form metadata.
form.api.reset(nextInput)Install nextInput as the clean baseline.
Change defaultValuesNo effect until the hook remounts.
Change the editor keyCreate a new form for another document.

Update the saved baseline without replacing new edits

Requires React Hook Form 7.77.0 or newer.

Use resetDefaultValues after the server saves the submitted input. This operation changes the clean baseline. It does not replace the current field values.

export function SavedBaselineRecipe() {
	const [savedName, setSavedName] = useState<string>()
	const isClientReady = useClientReady()
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: recipeProfile,
		onSubmit: async ({ input, value, form }) => {
			await saveProfileSnapshot(value)
			form.api.resetDefaultValues(input)
			setSavedName(value.name)
		},
	})
	const state = useFormState({ control: form.api.control })
	let status = "No unsaved changes"
	if (state.isDirty) status = "Unsaved changes"
	if (state.isSubmitting) status = "Saving. You can continue to edit."
 
	return (
		<section
			aria-label="Saved baseline recipe preview"
			className="form-please-complex"
			data-demo-client-ready={isClientReady}
		>
			<p className="form-please-complex__kicker">Live preview</p>
			<p className="form-please-complex__summary">
				Submit the form. Change the name before the save operation finishes.
			</p>
			<nativeFormKit.AutoForm form={form}>
				<div className="form-please-complex__actions">
					<nativeFormKit.Submit className="form-please-complex__primary">
						Save current values
					</nativeFormKit.Submit>
					<output aria-live="polite">{status}</output>
				</div>
			</nativeFormKit.AutoForm>
			{savedName !== undefined && (
				<output aria-live="polite">Saved baseline: {savedName}</output>
			)}
		</section>
	)
}

The preview waits before it completes the save operation. Submit the form, then change the name. The new name stays in the field and the form stays dirty.

Live preview

Submit the form. Change the name before the save operation finishes.

No unsaved changes

Use the submitted input when the server accepts that exact input. Use the server's canonical schema input when the server returns different editable values.

Keep dependent values atomic

Configure value middleware when one generated change must update dependent fields before observers see the result. Call form.update when an application action needs the same managed boundary.

For one form-local rule, use beforeUpdate to adjust or cancel the proposal and afterUpdate to observe its final committed transaction. Use middleware when independent policies must compose in an explicit order.

function ProfileUpdateHooks({ context }: { readonly context: ProfileContext }) {
	const form = profileKit.useForm(profileDefinition, {
		beforeUpdate(draft, transaction) {
			if (
				!context.canEditPlan &&
				transaction.source.type === "control" &&
				transaction.source.path === "plan"
			) {
				return false
			}
			if (draft.plan === "solo") draft.teamName = undefined
		},
		afterUpdate(transaction) {
			recordManagedValues(transaction.nextValues)
		},
		context,
		defaultValues,
	})
 
	return <profileKit.AutoForm form={form} />
}

See Value middleware for complete setup, live previews, cancellation, ordering, array constraints, and validation timing.

function recordManagedValues(_values: DeepReadonly<ProfileInput>) {}
 
const keepPlanValuesConsistent: FormMiddleware<ProfileInput, ProfileContext> =
	(api) => (next) => (transaction) => {
		let patches = transaction.patches
		if (
			transaction.nextValues.plan === "solo" &&
			transaction.nextValues.teamName !== undefined
		) {
			patches = [
				...transaction.patches,
				{ op: "replace", path: ["teamName"], value: undefined },
			]
		}
		const result = next(patches)
		// `next` commits synchronously, so this reads the complete final value.
		recordManagedValues(api.getValues())
		return result
	}

Middleware receives Immer patches and a nextValues value derived from them. Forward the original patches, append dependent patches, or return without next to cancel the complete proposal. Code after synchronous next reads the committed final value.

Generated controls and form.update make one RHF value publication. Generated array actions retain native useFieldArray row identity and promise one final React render, not one raw RHF publication. Direct form.api operations bypass middleware, so keep invariant-preserving application actions on form.update.

Only the proposed generated array action can change generated array length or order. Do not change another generated array's structure in the same transaction. Use that array's generated action to keep row IDs synchronized. Use raw useFieldArray methods only when bypassing middleware is intentional.

Do not call next after await, call it twice, or start another managed update inside an active transaction. Use an RHF subscription when you only need to observe values; subscriptions run after commit and cannot make dependent changes atomic.

Apply one update to multiple fields

Form, Please requires React Hook Form 7.76.1 or newer.

Use raw setValues when one application action changes multiple fields but must deliberately bypass Form, Please middleware. One call updates the fields and applies the same dirty, touch, and validation options to each value.

export function AtomicValuesRecipe() {
	const isClientReady = useClientReady()
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: recipeProfile,
	})
	const [templateApplied, setTemplateApplied] = useState(false)
	let status = "No template applied"
	if (templateApplied) status = "Profile template applied."
 
	return (
		<section
			aria-label="Atomic values recipe preview"
			className="form-please-complex"
			data-demo-client-ready={isClientReady}
		>
			<p className="form-please-complex__kicker">Live preview</p>
			<p className="form-please-complex__summary">
				Apply one template to multiple fields.
			</p>
			<nativeFormKit.AutoForm form={form}>
				<div className="form-please-complex__actions">
					<button
						type="button"
						onClick={() => {
							form.api.setValues(
								{
									name: "Grace Hopper",
									email: "grace@example.com",
									department: "Compilers",
								},
								{ shouldDirty: true, shouldValidate: true },
							)
							setTemplateApplied(true)
						}}
					>
						Apply profile template
					</button>
					<output aria-live="polite">{status}</output>
				</div>
			</nativeFormKit.AutoForm>
		</section>
	)
}

The preview applies one profile template to three fields.

Live preview

Apply one template to multiple fields.

No template applied

Pass only the fields that the action owns. Do not use setValues to install a new document baseline. Use reset(nextInput) for that operation. Use form.update instead when application middleware must enforce invariants.

Save a draft with a subscription

Requires React Hook Form 7.76.1 or newer, which is the package peer minimum.

Use subscribe when an external process needs current form values. The subscription does not rerender the form when values change. This preview updates application state only to show the saved draft.

export function DraftSubscriptionRecipe() {
	const [savedDraft, setSavedDraft] = useState<Profile>()
	const isClientReady = useClientReady()
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: recipeProfile,
	})
	const api = form.api
 
	useEffect(() => {
		let saveTimer: ReturnType<typeof setTimeout> | undefined
		const unsubscribe = api.subscribe({
			formState: { values: true },
			callback: ({ values }) => {
				if (saveTimer !== undefined) clearTimeout(saveTimer)
				saveTimer = setTimeout(() => {
					// Replace this state update with your draft storage call.
					setSavedDraft(values)
				}, 400)
			},
		})
 
		return () => {
			if (saveTimer !== undefined) clearTimeout(saveTimer)
			unsubscribe()
		}
	}, [api])
	let status = "Edit a field to save a draft."
	if (savedDraft !== undefined) {
		let draftName = savedDraft.name
		if (draftName === "") draftName = "the unnamed profile"
		status = `Draft saved for ${draftName}.`
	}
 
	return (
		<section
			aria-label="Draft subscription recipe preview"
			className="form-please-complex"
			data-demo-client-ready={isClientReady}
		>
			<p className="form-please-complex__kicker">Live preview</p>
			<p className="form-please-complex__summary">
				Edit a field. The preview saves the draft after 400 ms.
			</p>
			<nativeFormKit.AutoForm form={form} />
			<output aria-live="polite">{status}</output>
		</section>
	)
}

The preview waits 400 ms after the last change, then stores the current draft.

Live preview

Edit a field. The preview saves the draft after 400 ms.

Edit a field to save a draft.

Return the unsubscribe function from the effect cleanup. Do not call form mutation methods from the subscription callback.

Validate one wizard step

Requires React Hook Form 7.76.1 or newer, which is the package peer minimum.

Use trigger with the paths for the current step. Use setFocus when the step contains an invalid field. Hidden fields keep their values when the next step renders.

export function StepValidationRecipe() {
	const [step, setStep] = useState<WizardStep>("identity")
	const [saved, setSaved] = useState(false)
	const isClientReady = useClientReady()
	const context = useMemo(() => ({ step }), [step])
	const form = wizardKit.useForm(wizardDefinition, {
		context,
		defaultValues: { name: "", email: "", department: "" },
		onSubmit: () => setSaved(true),
	})
 
	async function showDetails() {
		for (const path of identityFields) {
			form.api.setValue(path, form.api.getValues(path), { shouldTouch: true })
		}
		const valid = await form.api.trigger(identityFields)
		if (!valid) {
			const firstInvalid = identityFields.find(
				(path) => form.api.getFieldState(path).invalid,
			)
			if (firstInvalid !== undefined) form.api.setFocus(firstInvalid)
			return
		}
		setStep("details")
	}
	let stepLabel = "1 of 2: identity"
	let actions = (
		<button type="button" onClick={() => void showDetails()}>
			Continue
		</button>
	)
	if (step === "details") {
		stepLabel = "2 of 2: details"
		actions = (
			<>
				<button type="button" onClick={() => setStep("identity")}>
					Back
				</button>
				<wizardKit.Submit className="form-please-complex__primary">
					Save profile
				</wizardKit.Submit>
			</>
		)
	}
	let status = "Complete the current step."
	if (saved) status = "Profile saved."
 
	return (
		<section
			aria-label="Step validation recipe preview"
			className="form-please-complex"
			data-demo-client-ready={isClientReady}
		>
			<p className="form-please-complex__kicker">Live preview</p>
			<p className="form-please-complex__summary">Step {stepLabel}</p>
			<wizardKit.AutoForm form={form}>
				<div className="form-please-complex__actions">{actions}</div>
			</wizardKit.AutoForm>
			<output aria-live="polite">{status}</output>
		</section>
	)
}

The preview marks the current fields as touched before validation. This action makes their validation messages visible without a form submission.

Live preview

Step 1 of 2: identity

Complete the current step.

Keep the complete form rules in the Standard Schema. The path list controls step navigation. It does not create a second validation contract.

For typed paths, conditional screens, progress, review, and first-invalid navigation, follow the complete Product workflows tutorial.

Keep async submission in the application

Return the request promise from onSubmit. React Hook Form keeps isSubmitting true until that promise settles. kit.Submit disables its button while validation or submission is in progress.

function SavingProfile({ profile }: { readonly profile: Profile }) {
	const [requestError, setRequestError] = useState<string>()
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: profile,
		onSubmit: async ({ value, form }) => {
			setRequestError(undefined)
			try {
				const saved = await updateProfile(value)
				form.api.reset(saved)
			} catch (error) {
				setRequestError(getRequestErrorMessage(error))
			}
		},
	})
	const state = useFormState({ control: form.api.control })
	let submitState = <output aria-live="polite">Ready</output>
	if (state.isSubmitting) {
		submitState = <output aria-live="polite">Saving…</output>
	}
 
	return (
		<nativeFormKit.AutoForm form={form}>
			{requestError !== undefined && <p role="alert">{requestError}</p>}
			{submitState}
			<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
		</nativeFormKit.AutoForm>
	)
}

Clear an old request error before each attempt. Render the new failure from application state. Reset the form only after the server returns a successful, canonical input.

A transport failure is not a schema issue. Keep it out of the form error summary. If the server identifies invalid field values, adapt that response to the schema or RHF setError.

The visible kit.Submit prevents normal duplicate clicks. If application code calls form.api.handleSubmit(onValid, onInvalid), that code must also prevent concurrent calls. Raw handleSubmit does not invoke the configured "Form, Please" wrapper.

Apply server field errors

Use form.api.setError when the server identifies a specific editable field. Keep request and form-level failures in application state.

First, define and validate the server response:

const serverProfileResultSchema = z.discriminatedUnion("ok", [
	z.object({
		ok: z.literal(true),
		input: profileSchema,
	}),
	z.object({
		ok: z.literal(false),
		formError: z.string().optional(),
		fieldErrors: z.array(
			z.object({
				path: z.enum(["name", "email", "department"]),
				message: z.string(),
			}),
		),
	}),
])
 
type ServerProfileResult = z.output<typeof serverProfileResultSchema>
 
async function saveProfileWithValidation(
	profile: Profile,
): Promise<ServerProfileResult> {
	const response = await fetch(`/api/profiles/${profile.id}`, {
		method: "PUT",
		headers: { "content-type": "application/json" },
		body: JSON.stringify(profile),
	})
	const body: unknown = await response.json()
	return serverProfileResultSchema.parse(body)
}

Then apply only the validated field paths:

function ProfileWithServerValidation({
	profile,
}: {
	readonly profile: Profile
}) {
	const [requestError, setRequestError] = useState<string>()
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: profile,
		onSubmit: async ({ value, form }) => {
			setRequestError(undefined)
			form.api.clearErrors(["name", "email", "department"])
 
			try {
				const result = await saveProfileWithValidation(value)
				if (result.ok) {
					form.api.reset(result.input)
					return
				}
 
				setRequestError(result.formError)
				for (const [index, issue] of result.fieldErrors.entries()) {
					form.api.setError(
						issue.path,
						{ type: "server", message: issue.message },
						{ shouldFocus: index === 0 },
					)
				}
			} catch {
				setRequestError("The profile could not be saved")
			}
		},
	})
 
	return (
		<nativeFormKit.AutoForm form={form}>
			{requestError !== undefined && <p role="alert">{requestError}</p>}
			<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
		</nativeFormKit.AutoForm>
	)
}

Validate the server response before you apply it. Map only known server paths to schema input paths. A generated control displays the error after the submit attempt. shouldFocus moves focus to the first server field error.

Clear old server errors before the next request. Do not convert network, authorization, or service failures into field errors.

Send schema output and reset with schema input

Standard Schema can transform data. In that case, editable input and submitted output are different contracts.

function NormalizedProfileEditor({
	profile,
}: {
	readonly profile: NormalizedProfileInput
}) {
	const form = nativeFormKit.useForm(normalizedProfileDefinition, {
		defaultValues: profile,
		onSubmit: async ({ value, form }) => {
			// value has trimmed names and lower-case email addresses.
			const savedInput = await saveNormalizedProfile(value)
			// reset requires schema input, not transformed schema output.
			form.api.reset(savedInput)
		},
	})
 
	return (
		<nativeFormKit.AutoForm form={form}>
			<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
		</nativeFormKit.AutoForm>
	)
}

onSubmit receives these values:

  • input is the current FormInput<Schema>.
  • value is the parsed FormOutput<Schema>.
  • form is the current form binding.
  • submitter is the native submit control snapshot or null.

Send value when the server expects parsed output. Pass schema input to form.api.reset. If the server returns only output, convert it to editable input at the application boundary.

Keep schema parsing deterministic. Form, Please parses once for validation and returns the transformed output from that parse. The schema must still be free of side effects because React Hook Form can validate it again after later user actions.

Choose the transport format

"Form, Please" does not serialize the submit value. Send JSON when the server accepts the parsed object:

async function postProfile(value: FormOutput<typeof profileSchema>) {
	const response = await fetch("/api/profiles", {
		method: "POST",
		headers: { "content-type": "application/json" },
		body: JSON.stringify(value),
	})
 
	if (!response.ok) {
		throw new Error("The profile could not be saved")
	}
}

Build FormData explicitly when the request includes a file:

const uploadSchema = z.object({
	title: z.string().trim().min(1),
	attachment: z.file().optional(),
})
 
function createUploadBody(value: FormOutput<typeof uploadSchema>): FormData {
	const body = new FormData()
	body.set("title", value.title)
	if (value.attachment !== undefined) {
		body.set("attachment", value.attachment)
	}
	return body
}

Do not set the content-type header when fetch sends FormData. The browser adds the multipart boundary. Handle non-success responses with the async submission pattern above.

Pass request state through context

Load remote data before or beside the form. Convert the current request result to ResourceState, then pass it through form context. fromResource converts that state into synchronous UI values.

const directoryDefinition = directoryKit.defineForm(profileSchema, {
	ui: [
		{ kind: "field", path: "name", control: "text", label: "Name" },
		{
			kind: "field",
			path: "department",
			control: "select",
			label: "Department",
			description: departmentDescription,
			props: departmentProps,
			options: departmentOptions,
			disabled: (_values, { context }) =>
				context.departments.status !== "success",
		},
	],
})
 
function DirectoryProfile({ context }: { readonly context: DirectoryContext }) {
	const form = directoryKit.useForm(directoryDefinition, {
		defaultValues: emptyProfile,
		context,
	})
	return <directoryKit.AutoForm form={form} />
}

The example derives select options, description text, and disabled state from one department resource. Each resolver receives the complete schema input and the current context.

fromResource does not fetch, cache, retry, or cancel a request. The application or request library owns that work. A resolver must return immediately and must not update form state.

See Resource state for pending, success, error, refresh, and TanStack Query examples.

Choose disabled or read-only mode

Set form-wide interaction state when permissions or workflow state apply to all generated fields.

function ProfileByMode({
	profile,
	mode,
}: {
	readonly profile: Profile
	readonly mode: ProfileMode
}) {
	const form = nativeFormKit.useForm(profileDefinition, {
		defaultValues: profile,
		disabled: mode === "disabled",
		readOnly: mode === "read-only",
	})
 
	return (
		<nativeFormKit.AutoForm form={form}>
			{mode === "edit" && (
				<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
			)}
		</nativeFormKit.AutoForm>
	)
}

disabled disables generated controls and array actions. It also prevents kit.Form from submitting. readOnly requests read-only behavior from each control, but it does not disable the submit button.

Omit the submit action outside edit mode. A custom control must implement both states according to its platform semantics.

Preserve the accessibility contract

Generated field slots connect labels, descriptions, and issue output to a control. A custom control must attach the supplied properties to its interactive element.

const currency = defineControl<number | undefined, CurrencyProps>({
	component: CurrencyControl,
})
 
const billingKit = createFormKit({
	controls: { ...createNativeControls(), currency },
	slots: createDefaultSlots(),
})

A custom control must:

  1. Attach input.id, input.name, and input.ref.
  2. Attach input["aria-describedby"] and expose meta.invalid.
  3. Call setValue after a valid control change.
  4. Call blur from its blur event.
  5. Honor disabled, readOnly, and required.

Custom slots must preserve semantic groups, labels, issue output, and usable buttons. Test keyboard input, focus, invalid state, and read-only behavior for each reusable custom control.

Test the public experience

Test the form through public package imports and visible behavior:

  1. Render the component that calls kit.useForm.
  2. Find controls by their labels and roles.
  3. Enter schema input and submit with the visible button.
  4. Assert issues, focus, parsed output, and pending UI.
  5. Test request and resource branches as application behavior.

Do not test private definition normalization or focus maps. Test a reusable custom control once against ControlProps. Test each product form as a user operates it.

Read the API reference for individual contracts. Read Controls and slots for design-system integration.