Skip to content
Form, Please

API

Use this page to find public imports, configuration, return values, and runtime behavior. Form, Please adds typed UI definitions to React Hook Form. Each form binding exposes the complete React Hook Form API as form.api.

For exported TypeScript types and their relationships, read the TypeScript reference.

All TypeScript examples on this page come from one file. The documentation build type-checks that file against the public package entries.

Install

Install the package, its required React Hook Form peer, and React:

npm install form-please react-hook-form react react-dom

Import the optional layout styles when you use the supplied grid contract:

import "form-please/layout.css"

Entry points

ImportRuntime exportsPurpose
form-pleasedefineControl, createFormKit, fromResource, matchResource, useSnapshotBuild kits, controls, definitions, resource resolvers, and external-store adapters.
form-please/default-slotscreateDefaultSlotsCreate accessible, unstyled structural slots.
form-please/historycreateHistoryMiddleware, useHistoryAdd optional managed value history and journal transfer.
form-please/native-controlscreateNativeControlsCreate the native HTML control registry.
form-please/persistencecreatePersistenceMiddleware, createLocalStorageAdapter, createDateCodec, usePersistenceRestore and autosave editable drafts through application storage.
form-please/preset-nativenativeFormKitUse the ready-made native kit with English slots.
form-please/preset-muicreateMuiFormKitCreate a Material UI 9 kit.

form-please/layout.css and form-please/package.json are non-JavaScript exports. React UI, history, and persistence entries are client modules.

useSnapshot

useSnapshot(store) subscribes a component to any external store with stable subscribe(listener) and getSnapshot() functions. The snapshot type is inferred from getSnapshot. Its result must stay Object.is-equal until the store changes. The same getter supplies the server snapshot, so it must also be safe and consistent during server rendering.

const storeSnapshot = { status: "ready" as const }
const store = {
	getSnapshot: () => storeSnapshot,
	subscribe: (_listener: () => void) => () => undefined,
}
 
function ExternalStoreStatus() {
	const snapshot = useSnapshot(store)
	return <output>{snapshot.status}</output>
}

defineControl

defineControl<Value, OwnProps, Context, Option>() creates one control definition. Its generic parameters connect the control to compatible schema paths, application-owned props, selectable options, and form context.

type UppercaseProps = {
	readonly placeholder?: string
}
 
function UppercaseControl({
	value,
	setValue,
	blur,
	input,
	meta,
	props: inputProps,
	disabled,
	readOnly,
	required,
}: ControlProps<string | undefined, UppercaseProps>) {
	return (
		<input
			aria-describedby={input["aria-describedby"]}
			aria-invalid={meta.invalid || undefined}
			disabled={disabled}
			id={input.id}
			name={input.name}
			onBlur={blur}
			onChange={(event) =>
				setValue(event.currentTarget.value.toUpperCase() || undefined)
			}
			placeholder={inputProps.placeholder}
			readOnly={readOnly}
			ref={input.ref}
			required={required}
			value={value ?? ""}
		/>
	)
}
 
const uppercase = defineControl<string | undefined, UppercaseProps>({
	component: UppercaseControl,
})

The control component receives these props:

PropMeaning
pathCurrent RHF field path. Array indexes use dot notation.
valueCurrent field value. Its type is the first defineControl generic.
setValue(value)Replace the current field value.
blur()Mark the field as blurred. Call it from the interactive element.
inputGenerated id, name, focus ref, and optional aria-describedby.
metaDirty, touched, validating, error, and displayed-error state.
propsDeeply readonly application-owned props from the field definition.
optionsCurrent readonly collection for an option-capable control.
contextDeeply readonly context from useForm.
disabled, readOnlyResolved form, parent, and field interaction state.
requiredResolved presentation state. It does not add schema validation.

Attach all input properties to the interactive element. Call blur from its blur event. Use meta.invalid for aria-invalid.

meta.errors contains all normalized field issues. meta.displayErrors contains the issues that the generated UI currently displays. Before the first submit, the UI displays issues only after the user touches the field.

The function freezes the returned definition. It rejects any, unknown, and never as the control value type during type checking.

createFormKit

createFormKit({ controls, slots, grid? }) creates and freezes one complete design-system contract.

const kit = createFormKit({
	controls: {
		...createNativeControls(),
		uppercase,
	},
	slots: createDefaultSlots(),
	grid: [1, 2, 4],
})
OptionRequirement
controlsA record of control definitions. Control names become definition values.
slotsComponents for fields, sections, arrays, array items, errors, and submit buttons.
gridOptional positive integer list. It must include 1 and contain no duplicates.

The default grid is [1, 2, 3, 4]. The function sorts a custom grid and freezes copies of the controls, slots, and grid. Add all controls before this call. A kit does not support runtime extension.

The returned kit has these members:

MemberResult
controls, slots, gridFrozen registry snapshots.
defineFragmentCreates a reusable schema-owned UI fragment.
defineFormCreates a typed and normalized form definition.
forContextReturns a type-only context view of the same kit.
useFormCreates a component-local form binding.
Form, Fields, Submit, AutoFormRender the form and its generated UI.

defineFragment

kit.defineFragment(schema, (ui) => [...]) returns a frozen fragment with the exact input schema at fragment.schema and placement methods at fragment.fields. The object form defineFragment(schema, { ui }) remains supported. Fragment-authored resolvers receive the local fragment input. See Reusable fragments for composition, context, and array examples.

Native controls

createNativeControls() returns a fresh frozen registry on each call.

ControlValue typeProps and selectable options
textstring | undefinedtype, placeholder, autoComplete
textareastring | undefinedplaceholder, autoComplete, rows
selectstring | undefinedField options; optional props.emptyOption
checkboxbooleanNo props or options
numbernumber | undefinedmin, max, step, placeholder
datestring | undefinedmin, max
timestring | undefinedmin, max, step
fileFile | undefinedaccept

Use props.emptyOption when a select value can be undefined. Do not also add an option whose value is an empty string.

const preferencesSchema = z.object({
	email: z.string().optional(),
	plan: z.enum(["solo", "team"]).optional(),
	seats: z.number().optional(),
	newsletter: z.boolean(),
})
 
const preferencesDefinition = nativeFormKit.defineForm(preferencesSchema, {
	ui: [
		{
			kind: "field",
			path: "email",
			control: "text",
			label: "Email",
			props: { type: "email", autoComplete: "email" },
		},
		{
			kind: "field",
			path: "plan",
			control: "select",
			label: "Plan",
			options: [
				{ value: "solo", label: "Solo" },
				{ value: "team", label: "Team" },
			],
			props: {
				emptyOption: { label: "Select a plan" },
			},
		},
		{
			kind: "field",
			path: "seats",
			control: "number",
			label: "Seats",
			props: { min: 1, max: 100, step: 1 },
		},
		{
			kind: "field",
			path: "newsletter",
			control: "checkbox",
			label: "Send product news",
		},
	],
})

Import the option types from form-please/native-controls. The entry exports NativeTextProps, NativeTextareaProps, NativeSelectProps, NativeNumberProps, NativeDateProps, NativeTimeProps, and NativeFileProps. It also exports the related select and text types.

Default slots

createDefaultSlots() returns accessible field, section, array, array-item, error, and submit structure without a visual theme.

const nativeControls = createNativeControls()
const localizedDefaultSlots = createDefaultSlots({
	i18n: {
		arrayAdd: "Add another item",
		arrayRemove: ({ position }) => `Remove item ${position}`,
	},
})
const localizedNativeKit = createFormKit({
	controls: nativeControls,
	slots: localizedDefaultSlots,
})

Use i18n to replace any English array action label:

const fullyLocalizedSlots = createDefaultSlots({
	i18n: {
		arrayAdd: ({ label }) => {
			if (typeof label === "string") return `Add ${label}`
			return "Add item"
		},
		arrayRemove: ({ position }) => `Remove item ${position}`,
		arrayMoveUp: ({ position }) => `Move item ${position} up`,
		arrayMoveDown: ({ position }) => `Move item ${position} down`,
	},
})
KeyCallback data
arrayAdd{ label } from the array node
arrayRemoveZero-based index and one-based position
arrayMoveUpZero-based index and one-based position
arrayMoveDownZero-based index and one-based position

Each value can be a string or a callback that returns a string. Import DefaultSlotsI18n and its related types from form-please/default-slots.

Native preset

Use nativeFormKit when the native controls and default English slots are the required baseline.

const readyNativeKit = nativeFormKit

Use the factories when the application needs localized slots or additional controls.

Material UI preset

Install Material UI 9 and Emotion before you import the preset:

npm install @mui/material @emotion/react @emotion/styled

createMuiFormKit() returns a new frozen kit with Material UI controls, slots, and a 12-column grid.

const muiKit = createMuiFormKit({
	i18n: {
		addItem: "Add item",
		removeItem: (position) => `Remove item ${position}`,
		moveItemUp: (position) => `Move item ${position} up`,
		moveItemDown: (position) => `Move item ${position} down`,
		chooseFile: "Choose file",
	},
})

The i18n object supports addItem, removeItem, moveItemUp, moveItemDown, and chooseFile. All properties are optional.

The registry contains these controls:

Value typeControl namesProps type
string | undefinedtext, textarea, password, email, url, tel, search, date, time, datetime-localMuiTextFieldProps
number | undefinednumberMuiTextFieldProps
string | undefinedselectMuiSelectProps
readonly string[]select-multipleMuiSelectMultipleProps
string | undefinedradioMuiRadioProps
booleancheckbox, switchMuiCheckboxProps or MuiSwitchProps
string | undefinedautocompleteMuiAutocompleteProps
readonly string[]autocomplete-multipleMuiAutocompleteMultipleProps
File | undefinedfileMuiFileProps
readonly File[]filesMuiFileProps
numbersliderMuiSliderProps
readonly number[]range-sliderMuiRangeSliderProps

Select, radio, and autocomplete field options narrow to the schema input union at the selected path. Multiple controls use the array element union. Custom select children and autocomplete freeSolo values remain outside that inference contract.

const muiSettingsSchema = z.object({
	role: z.string().optional(),
	topics: z.array(z.string()),
	notifications: z.boolean(),
	priority: z.number(),
})
 
const muiSettingsDefinition = muiKit.defineForm(muiSettingsSchema, {
	ui: [
		{
			kind: "field",
			path: "role",
			control: "select",
			label: "Role",
			options: [
				{ value: "developer", label: "Developer" },
				{ value: "designer", label: "Designer" },
			],
			props: {
				displayEmpty: true,
			},
		},
		{
			kind: "field",
			path: "topics",
			control: "autocomplete-multiple",
			label: "Topics",
			options: ["React", "TypeScript", "Accessibility"],
		},
		{
			kind: "field",
			path: "notifications",
			control: "switch",
			label: "Notifications",
		},
		{
			kind: "field",
			path: "priority",
			control: "slider",
			label: "Priority",
			props: { min: 0, max: 10, step: 1 },
		},
	],
})

The preset also exports its control props, option, slot-option, and localization types. The application owns ThemeProvider, CssBaseline, and theme configuration. See the Material UI example for a complete form.

defineForm

kit.defineForm(schema, (ui) => [...]) validates, normalizes, and freezes a typed definition. The schema-bound field, section, array, and render helpers create ordinary nodes without requiring an authored kind property. The schema must implement Standard Schema. Field paths and control values use the schema input type, before schema transformations.

const profileDefinition = profileKit.defineForm(profileSchema, (ui) => [
	ui.section("identity", {
		title: "Profile",
		columns: 2,
		children: [
			ui.field("name", {
				control: "uppercase",
				label: "Display name",
				props: { placeholder: "ADA" },
				required: true,
			}),
			ui.field("yearsOfExperience", {
				control: "text",
				label: "Years of experience",
			}),
			ui.field("plan", {
				control: "select",
				label: "Plan",
				readOnly: (_values, { context }) => !context.canEditPlan,
				options: [
					{ value: "solo", label: "Solo" },
					{ value: "team", label: "Team" },
				],
			}),
			ui.field("teamName", {
				control: "text",
				label: "Team name",
				visible: (values) => values.plan === "team",
			}),
			teamHint,
			ui.field("country", {
				control: "text",
				label: "Country",
				description: countryDescription,
			}),
		],
	}),
	ui.array("speakers", {
		label: "Speakers",
		itemDefault: { name: "" },
		children: (speaker) => [
			speaker.field("name", { control: "text", label: "Name" }),
		],
	}),
])

array.children receives another builder bound to the item scope. The root builder and each array child callback run once while the definition is created; they are not value resolvers. The object form defineForm(schema, { ui: [...] }) remains supported and produces the same normalized definition.

In addition to opaque fragment placements, the UI array accepts four ordinary node kinds:

KindRequired propertiesPurpose
fieldpath, controlConnect one schema input path to one registered control.
sectionid, childrenGroup nodes without changing their path scope.
arraypath, itemDefault, childrenRepeat nodes in the scope of one array item.
renderid, componentInsert form-local React content.

Field, section, and array nodes support resolved labels or descriptions. They also support visible, disabled, readOnly, className, span, and typed slotOptions. Fields add required, typed control props, and selectable options when the control supports them. Sections add title and columns.

Each supported property except field options accepts a static value or a synchronous resolver. An ordinary node resolver receives the complete deeply readonly schema input and { context }; a fragment-authored resolver receives its local fragment input. Form, Please resolves the complete tree after each value change. It throws when an ordinary resolver returns a promise. Field options may be an async ({ values, context, signal }) => options function with tracked dependencies, cancellation, and [] fallback behavior. A request that rejects renders an empty collection. A resolver that successfully returns a non-array value violates its contract and throws a TypeError through React.

Hidden fields keep their React Hook Form values. The required property changes the UI state only. Put every data rule in the schema.

An array child path is relative to one item. itemDefault accepts a complete item or a function that returns one. Form, Please clones the value before each insertion. Array paths use their current numeric index, while React keys use RHF's stable field-array ID.

A render component inherits resolved interaction state:

function TeamHint({ disabled, readOnly }: RenderNodeProps) {
	return (
		<p
			data-disabled={disabled || undefined}
			data-readonly={readOnly || undefined}
		>
			Team accounts can invite additional collaborators.
		</p>
	)
}
 
const teamHint = {
	kind: "render",
	id: "team-hint",
	component: TeamHint,
	visible: (values) => values.plan === "team",
} satisfies RenderNode<ProfileInput, ProfileContext>

The runtime rejects malformed schemas, unknown controls, invalid path syntax, duplicate node IDs, missing children, and grid values outside the kit grid.

A definition belongs to the exact kit that created it. Keep the definition stable for the hook lifetime. To use another definition, remount the component with a different React key.

forContext

kit.forContext<Context>() returns a type-only view of the same runtime kit. It makes context mandatory in useForm. It also types resolvers and compatible controls.

const profileKit = kit.forContext<ProfileContext>()

The method does not create or clone a registry. Its context type must extend the current kit context.

useForm

kit.useForm(definition, options) creates a component-local FormBinding.

OptionRequirement and effect
defaultValuesRequired complete synchronous schema input, fixed for the hook lifetime.
contextRequired after forContext<Context>(); otherwise optional.
disabledDisables generated controls and native form submission.
readOnlyLocks generated controls but does not disable submission.
modeOptional RHF validation mode. Defaults to onSubmit.
reValidateModeOptional RHF revalidation mode. Defaults to onChange.
delayErrorOptional RHF error-display delay in milliseconds.
beforeUpdateAdjust or cancel one managed value proposal before middleware.
afterUpdateObserve the final committed transaction after middleware unwinds.
middlewareOrdered Redux-shaped value middleware, fixed for the hook lifetime.
onSubmitReceives parsed output, editable input, the current binding, and a submitter snapshot.

Use FormSubmitDetails<Schema, Context> from form-please when a reusable submit handler needs the exact { value, input, form, submitter } parameter type.

PropertyTypeSource
valueFormOutput<Schema>The successful Standard Schema parse.
inputFormInput<Schema>A deep snapshot captured before validation.
formFormBinding<Schema, Context>The binding that received the native submit.
submitterReadonly<{ name: string; value: string }> | nullA frozen native submit-control snapshot captured before validation.
function ProfileEditor({ context }: { readonly context: ProfileContext }) {
	const form = profileKit.useForm(profileDefinition, {
		defaultValues,
		context,
		middleware: [keepPlanValuesConsistent],
		onSubmit: async ({ value, input, form }) => {
			// `input.yearsOfExperience` is a string from React Hook Form.
			// `value.yearsOfExperience` is the transformed number.
			await saveProfile(value)
			form.api.reset(input)
		},
	})
 
	return (
		<profileKit.AutoForm form={form}>
			<button
				onClick={() =>
					form.update((draft) => {
						draft.plan = "solo"
						draft.teamName = undefined
					})
				}
				type="button"
			>
				Use individual plan
			</button>
			<profileKit.Submit>Save profile</profileKit.Submit>
		</profileKit.AutoForm>
	)
}

Submission has this sequence:

  1. kit.Form captures the editable input and native submitter snapshots.
  2. The internal RHF resolver parses that input once with the Standard Schema.
  3. onSubmit receives { value, input, form, submitter } after parsing succeeds.
  4. A returned promise keeps form.api.formState.isSubmitting true until it settles.

The definition schema is the only form-level resolver. "Form, Please" fixes criteriaMode: "all", shouldUnregister: false, and RHF error focus.

submitter is a frozen { name, value } snapshot or null for an implicit submit. It is captured before validation and does not expose the live DOM element. Read the product workflow tutorial for named actions, async validation, and implicit-submit behavior.

Use form-wide state for review or locked workflows:

function ProfileReview({ context }: { readonly context: ProfileContext }) {
	const form = profileKit.useForm(profileDefinition, {
		defaultValues,
		context,
		readOnly: true,
	})
 
	return (
		<profileKit.Form form={form} aria-label="Profile review">
			<profileKit.Fields />
			<button type="reset">Restore initial values</button>
			<profileKit.Submit disabled>Save profile</profileKit.Submit>
		</profileKit.Form>
	)
}

FormBinding

A binding contains these public fields:

FieldPurpose
apiUnchanged typed RHF UseFormReturn for values, fields, validation, reset, and subscriptions.
definitionFixed definition captured during the first hook render.
contextCurrent runtime context.
update(recipe)Apply one managed Immer recipe through value middleware.

Form-wide interaction and focus bookkeeping remain private. Use form.api for raw RHF operations that deliberately bypass managed middleware.

Managed update hooks

Use beforeUpdate for one form-local rule that adjusts or cancels a managed proposal. Its Immer draft starts with the proposed nextValues. Mutate the draft and return nothing to continue, or return false to cancel the complete transaction.

Use afterUpdate to observe the final ValueTransaction after commit and the synchronous middleware unwind. Its patches and nextValues include downstream middleware changes.

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} />
}

The callbacks use their latest React render versions. Both must return synchronously and cannot start another managed update. An error from beforeUpdate prevents commit. An error from afterUpdate propagates without rolling committed values back. afterUpdate does not mean that React rendering or asynchronous validation has finished.

Initial values, reset, direct form.api mutations, and application-owned useFieldArray operations bypass both hooks. Use middleware instead when several independent policies need explicit composition. Cancellation and effective no-op proposals do not call afterUpdate.

Managed value middleware

Use middleware when a generated change and its dependent values must commit as one proposal, or when work must run only after that proposal commits.

Read Value middleware for runnable examples, live previews, ordering, cancellation, bypasses, 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 has the Redux shape api => next => transaction. The public types are FormMiddleware, FormMiddlewareApi, FormMiddlewareNext, ValueTransaction, ValueTransactionSource, ValuePatch, and FormUpdateRecipe.

A transaction contains deeply readonly TypeScript views of previousValues and derived nextValues. These views are not frozen or cloned as archival snapshots. The transaction also contains Immer patches, current context, and a discriminated source. Patch paths are segment arrays such as ["speakers", 0, "name"]; they are not RHF dot paths. next(patches) reapplies those authoritative patches to previousValues, commits the resulting values synchronously, and returns the terminal transaction unless another middleware replaces that return value.

Middleware can:

  • append or replace patches before calling next;
  • cancel the complete proposal by returning without next;
  • read final committed values and run post-change work after next returns;
  • return a promise after calling next synchronously.

One middleware can call next synchronously at most once. It cannot start a nested api.update during an active transaction. A later update after awaited post-commit work is allowed. An exception before next prevents the commit; an exception after next does not roll it back.

form.update(recipe) creates a managed transaction with source update. Its synchronous Immer recipe can mutate the draft or return a replacement, but not both. A no-op recipe creates no transaction. Redux middleware may replace the terminal return value, so form.update returns unknown.

Generated controls and form.update publish final values with one RHF setValues call. Generated append, remove, and move preserve native useFieldArray row IDs; their dependent patches join the same final React render, although raw RHF subscribers can observe an intermediate array state. Array middleware cannot change the source array length or order beyond the source action. It must not change another generated array's structure.

Only the proposed generated array action can change generated array length or order. setValues cannot synchronize the private row IDs in useFieldArray. Use each array's generated action for a managed structural change. Use a raw useFieldArray method only when the application deliberately bypasses middleware.

Do not change generated array length or order from a generated control or form.update. These entry points cannot synchronize useFieldArray row IDs.

Initial values, reset, and every direct form.api mutation bypass middleware. Managed updates cannot remove a top-level value because RHF shallow-merges roots; assign undefined when the schema permits it. Validation follows mode and reValidateMode after commit, but managed validation does not preserve delayError. RHF owns dirty and touched metadata.

Managed value history

createHistoryMiddleware(options?) and useHistory(form, feature) are exported only from form-please/history. Configure the returned feature in a form's middleware list, then pass it and the form to the hook. The hook result combines the exact form-specific handle with its reactive snapshot.

OptionDefaultEffect
limit100Maximum retained history groups; accepts a non-negative integer or Infinity.
groupWindow750Milliseconds in which control changes on one path share a group; zero disables grouping.

The handle exposes getSnapshot, subscribe, undo, redo, seek, clear, export, and import. Navigation and import return Promise<HistoryOperationResult>. clear() retains current values as the only position without changing the form.

History restore enters hooks and middleware with source history. The action is undo, redo, seek, or import. Restore can remove optional top-level keys because its RHF reset terminal replaces complete values. Ordinary form.update keeps its existing top-level-removal restriction.

HistoryJournal<Input> version 1 contains complete input entries and a numeric current index. Import checks the protocol and configured limit but does not require every editable entry to pass Standard Schema validation. Read Managed value history for state ownership, grouping, raw RHF boundaries, restoration, and journal leaf behavior.

Form persistence

createPersistenceMiddleware(options) and usePersistence(form, feature) are exported only from form-please/persistence. Configure the feature in the fixed middleware list, then pass it and the form to the hook. The hook starts restore after mount and combines the exact handle with its reactive snapshot.

OptionDefaultEffect
adapterRequiredKeyed asynchronous load, save, and remove transport.
keyRequiredApplication storage key.
versionRequiredNon-negative application data version.
saveDelay500Trailing autosave delay in milliseconds.
codecs[]Tagged encoders and decoders for explicit opaque values.
migrateNoneConverts decoded data from another application version.
onErrorNoneObserves failures with PersistenceErrorDetails, whose operation is restore, save, or clear.

The handle exposes restore, start, flush, clear, getSnapshot, and subscribe. Restore reports "applied", "empty", "cancelled", "transformed", or "conflict". Read Form persistence for lifecycle rules, envelope encoding, migrations, conflicts, and adapters.

Form, Fields, Submit, and AutoForm

ComponentProps and behavior
kit.FormRequires a binding from the same kit. Accepts native form props except owned submit, reset, action, and validation props.
kit.FieldsRenders the resolved definition. It renders optional children after generated fields.
kit.SubmitAccepts button props except type. The configured submit slot always receives type="submit".
kit.AutoFormRenders Form, the error summary, Fields, and then its children.

Fields and Submit must be descendants of the matching kit.Form. AutoForm provides that form context automatically.

Form prevents native submission and connects the "Form, Please" submit wrapper to raw form.api.handleSubmit. A reset event calls form.api.reset(). It sets noValidate because the Standard Schema owns validation.

Submit becomes disabled while validation or submission runs. It also follows the form-wide disabled option and its own disabled prop.

Use RHF hooks and components directly when generated fields are only part of the screen. This example adds an application-owned field and watches generated values:

function BillingReferenceField() {
	const id = useId()
	const { field, fieldState } = useController<ProfileInput, "billingReference">(
		{
			name: "billingReference",
		},
	)
	let errorId: string | undefined
	if (fieldState.error !== undefined) errorId = `${id}-error`
 
	return (
		<div>
			<label htmlFor={id}>Billing reference</label>
			<input
				{...field}
				aria-describedby={errorId}
				aria-invalid={fieldState.invalid || undefined}
				id={id}
				value={field.value ?? ""}
			/>
			{fieldState.error !== undefined && (
				<p id={errorId} role="alert">
					{fieldState.error.message}
				</p>
			)}
		</div>
	)
}
 
function ProfileWithCustomSummary({
	context,
}: {
	readonly context: ProfileContext
}) {
	const form = profileKit.useForm(profileDefinition, {
		defaultValues,
		context,
	})
	const plan = useWatch({ control: form.api.control, name: "plan" })
	const speakers = useWatch({ control: form.api.control, name: "speakers" })
	const state = useFormState({ control: form.api.control })
	let dirtyState = <output>Saved</output>
	if (state.isDirty) dirtyState = <output>Unsaved changes</output>
 
	return (
		<profileKit.Form form={form}>
			<profileKit.Fields />
			<BillingReferenceField />
			<output>Selected plan: {plan}</output>
			<output>{speakers.length} speakers</output>
			{dirtyState}
			<profileKit.Submit>Save profile</profileKit.Submit>
		</profileKit.Form>
	)
}

kit.Form provides form.api through RHF FormProvider. register, Controller, useController, useWatch, useFormState, useFieldArray, and useFormContext therefore share the generated form's state. Application-owned fields must provide their own labels, descriptions, issue output, and focus behavior. Their paths must still exist in the definition schema, but they do not need a node in the definition.

Resource helpers

matchResource and fromResource handle the three ResourceState branches: pending, success, and error.

const pendingCountries: CountryResource = { status: "pending" }
const loadedCountries: CountryResource = {
	status: "success",
	value: ["Canada", "Japan"],
}
const failedCountries: CountryResource = {
	status: "error",
	error: new Error("Country list is unavailable"),
}
HelperBehavior
matchResource(resource, cases)Selects one result immediately from a resource value.
fromResource(select, cases)Creates a synchronous UI resolver from application-owned resource state.

Every fromResource branch also receives the complete readonly form values and resolver details.

const selectCountries: UiResolver<
	CountryResource,
	ProfileInput,
	ProfileContext
> = (_values, { context }) => context.countries
 
const countryDescription = fromResource(selectCountries, {
	pending: () => "Loading countries",
	success: ({ value }, values) =>
		`${value.length} countries available for the ${values.plan} plan`,
	error: ({ error }) => error.message,
})

Use matchResource when you need a standalone value:

function describeCountries(countries: CountryResource) {
	return matchResource(countries, {
		pending: () => "Loading",
		success: ({ value }) => `${value.length} loaded`,
		error: ({ error }) => error.message,
	})
}

Both helpers require pending, success, and error branches. Neither helper fetches, caches, retries, cancels, or retains data. Read Resource state for a TanStack Query adapter.

Read Production recipes for complete composition and submission patterns.