Skip to content
Form, Please

Definitions

A definition connects one Standard Schema to the UI that edits its input. Call kit.defineForm(schema, (ui) => [...]) outside the component that uses the form. The schema-bound helpers keep paths, controls, options, and resolver values connected while hiding the runtime node discriminator.

import { nativeFormKit } from "form-please/preset-native"
import { z } from "zod"
 
const profileSchema = z.object({
	name: z.string().min(1),
	email: z.email(),
	company: z.string().optional(),
	contactByEmail: z.boolean(),
})
 
const profileDefinition = nativeFormKit.defineForm(profileSchema, (ui) => [
	ui.section("contact", {
		title: "Contact details",
		columns: 2,
		children: [
			ui.field("name", { control: "text", label: "Name" }),
			ui.field("email", {
				control: "text",
				label: "Email",
				props: { type: "email", autoComplete: "email" },
			}),
			ui.field("company", {
				control: "text",
				label: "Company",
				span: "full",
			}),
			ui.field("contactByEmail", {
				control: "checkbox",
				label: "Contact me by email",
				span: "full",
			}),
		],
	}),
])

The type checker verifies these connections:

  • Each path exists in the schema input.
  • Each control accepts the value at its field path.
  • Control props, selectable options, and slot options use their registered types.
  • Resolver context matches the kit context.
  • Array defaults match one complete array item.
  • columns and span use the kit grid.

defineForm also validates the tree at runtime. It rejects unknown controls, invalid path syntax, duplicate IDs, missing children, and invalid grid values. The object form defineForm(schema, { ui: [...] }) remains supported for generated definitions and direct node composition. Both authoring styles produce the same normalized tree.

Fields

A field connects one schema input path to one registered control. The path type comes from the schema input, not from a transformed schema output.

const schema = z.object({
	age: z.string().transform(Number),
})
 
const definition = nativeFormKit.defineForm(schema, (ui) => [
	ui.field("age", {
		control: "text", // The input value is a string.
		label: "Age",
		description: "Enter your age in years.",
		props: { placeholder: "42" },
		required: true,
	}),
])

control selects a control from the kit registry. props supplies application-owned props to that control instance. Only controls that display a selectable list accept options.

The required property describes the UI state. It does not add a validation rule. Put all data rules in the schema.

Option values

A selectable control can mark the value it writes with OptionValue<Value>. The field definition then narrows each option to the Standard Schema input type at its path; the form does not need a separate value generic.

const roleSchema = z.object({
	role: z.enum(["admin", "member"]),
})
 
const roleOptions = [
	{ value: "admin", label: "Administrator" },
] as const
 
nativeFormKit.defineForm(roleSchema, (ui) => [
	ui.field("role", {
		control: "select",
		options: [
			...roleOptions,
			{ value: "owner", label: "Owner" },
			//       ^^^^^^^ TypeScript rejects this value.
		],
	}),
])

The list may contain any subset of the field union; it does not have to be exhaustive. A Role | undefined field still accepts only Role as a normal option, while the native props.emptyOption represents undefined. A readonly Role[] field narrows options to the element union. A plain string field continues to accept arbitrary strings.

The same restriction applies when options is a function. Preserve literal types in context or resource data with as const, satisfies, or another explicit union type. A widened string[] cannot supply options for a literal schema union.

Sections

A section groups related nodes. It can contain fields, arrays, render nodes, and other sections. A section does not change the path scope of its children.

ui.section("delivery-address", {
	title: "Delivery address",
	description: "We use this address for the current order.",
	columns: 2,
	children: [
		ui.field("delivery.street", {
			control: "text",
			label: "Street",
			span: "full",
		}),
		ui.field("delivery.city", {
			control: "text",
			label: "City",
		}),
		ui.field("delivery.postcode", {
			control: "text",
			label: "Postcode",
		}),
	],
})

columns sets the grid for the section children. The default kit grid accepts 1, 2, 3, and 4. A custom kit can define a different grid. span must use a grid value that does not exceed the parent columns. Use "full" to span all parent columns.

Import form-please/layout.css to use the optional responsive grid. Custom slots can implement the same layout contract. See Styling.

Reusable fragments

kit.defineFragment(schema, (ui) => [...]) defines one schema-owned UI subtree and keeps the concrete schema available as fragment.schema. The object form defineFragment(schema, { ui }) remains supported. Compose that schema into a form schema, then place the fragment at each compatible object path:

type AddressContext = {
	readonly locale: string
}
 
const addressSchema = z.object({
	city: z.string(),
	street: z.string(),
})
 
const addressFragment = nativeFormKit
	.forContext<AddressContext>()
	.defineFragment(addressSchema, (ui) => [
		ui.field("street", {
			control: "text",
			label: (_address, { context }) => `${context.locale}: Street`,
		}),
		ui.field("city", { control: "text", label: "City" }),
	])
 
const checkoutSchema = z.object({
	billingAddress: addressFragment.schema,
	recipients: z.array(z.object({ address: addressFragment.schema })),
	shippingAddress: addressFragment.schema,
})
 
const checkoutKit = nativeFormKit.forContext<AddressContext>()
const checkoutDefinition = checkoutKit.defineForm(checkoutSchema, (ui) => [
	addressFragment.fields({ at: "shippingAddress" }),
	addressFragment.fields({ at: "billingAddress" }),
	ui.array("recipients", {
		itemDefault: { address: { city: "", street: "" } },
		children: () => [addressFragment.fields({ at: "address" })],
	}),
])

fragment.fields({ at }) creates one opaque authoring placement. defineForm expands it into ordinary fields, sections, arrays, and render nodes. The fragment does not add runtime state, a validation pass, or a lifecycle. It must come from the same runtime kit as the host definition.

The at path is relative to the current form or array-item scope. Its value must structurally satisfy the fragment schema input; additional object properties are allowed, while optional or incompatible objects are rejected. Call fragment.fields() without at when the current scope itself satisfies the fragment input, such as an array whose items are addresses.

Use kit.forContext<FragmentContext>() before defineFragment to declare the minimum context required by the fragment. A host context may provide additional properties. Resolvers inside the fragment receive the local deeply readonly fragment input and its required context. Put conditions that need the complete host input on a section or another ordinary node around the placement.

Arrays

An array node connects an array path to the definition for one item. Paths in children are relative to that item.

const eventSchema = z.object({
	speakers: z.array(
		z.object({
			name: z.string(),
			sessions: z.array(z.object({ title: z.string() })),
		}),
	),
})
 
const eventDefinition = nativeFormKit.defineForm(eventSchema, (ui) => [
	ui.array("speakers", {
		label: "Speakers",
		itemDefault: () => ({ name: "", sessions: [] }),
		children: (speaker) => [
			speaker.field("name", { control: "text", label: "Name" }),
			speaker.array("sessions", {
				label: "Sessions",
				itemDefault: { title: "" },
				children: (session) => [
					session.field("title", {
						control: "text",
						label: "Title",
					}),
				],
			}),
		],
	}),
])

Generated field paths use React Hook Form dot syntax. The title above can resolve to speakers.2.sessions.0.title.

itemDefault accepts a complete item or a function that returns one. Form, Please clones the result before each insertion. Use a function when the default needs a new value, such as a generated ID. This function takes no arguments and is not a UI resolver.

The children callback runs once while the definition is created. Its builder is bound to the array item, so each nested path is relative to that item. It is not a resolver and does not run for each rendered row.

Array paths use their current index, while RHF supplies a stable row key. Moving or removing a row changes later paths. Put a durable ID in the schema when identity must survive serialization or remounting. See Arrays for custom array actions.

Resolver values

Display, interaction, control props, and layout properties accept either a static value or a synchronous resolver.

const accountSchema = z.object({
	accountType: z.enum(["business", "nonprofit", "personal"]),
	company: z.string().optional(),
})
 
const accountKit = nativeFormKit.forContext<{
	readonly canEditCompanies: boolean
}>()
 
const accountDefinition = accountKit.defineForm(accountSchema, (ui) => [
	ui.field("company", {
		control: "text",
		label: (values) =>
			values.accountType === "business" ? "Company" : "Organization",
		visible: (values) => values.accountType !== "personal",
		disabled: (_values, { context }) => !context.canEditCompanies,
		props: (values) => ({
			placeholder:
				values.accountType === "nonprofit" ? "Foundation name" : "Company name",
		}),
	}),
])

For ordinary definition nodes, the first argument is the complete, deeply readonly schema input. This rule also applies inside array items. Resolvers authored by a reusable fragment receive that fragment's local input instead. The second argument contains the deeply readonly runtime context from kit.useForm.

Async options

The options collection is the one property that may load asynchronously. Its function receives one object, and the runtime tracks the values and context properties it reads:

ui.field("city", {
	control: "select",
	props: { emptyOption: { label: "Choose a city" } },
	options: async ({ values, context, signal }) => {
		const response = await fetch(
			`/api/cities?country=${values.country}&locale=${context.locale}`,
			{ signal },
		)
		return response.json()
	},
})

Changing values.country or context.locale aborts the previous request and runs the function again. Unrelated changes do not. The rendered list is empty during loading and after an uncaught error; catch inside the function when you want another fallback. The field value is preserved even when it is absent from the current list. A successfully resolved value must be an array; any other value is a resolver contract error surfaced through React.

Use an application resource or a custom control when you need loading or error UI, caching, retries, remote search, or pagination.

Resolvers must return immediately. A resolver that returns a promise causes a runtime error. Keep requests and cache state in the application. Pass their current result through context.

Form, Please resolves the complete UI tree after each form value change. It does not track resolver dependencies or cache resolver results. Keep resolvers small and free of side effects. Object and array results are compared shallowly when Form, Please reuses unchanged UI branches. Treat resolver results as immutable and replace nested values when their content changes.

See Conditional fields for conditional validation and value cleanup.

Visibility and interaction state

The default values are visible: true, disabled: false, readOnly: false, and required: false. A section or array passes its resolved state to all descendants.

{
	kind: "section",
	id: "billing",
	disabled: (values) => values.useSavedBillingAddress,
	children: [
		{
			kind: "field",
			path: "billingAddress.street",
			control: "text",
			label: "Street",
		},
	],
}

When a parent is disabled or read-only, a child cannot override that state. When a parent is hidden, Form, Please does not render its descendants. Hidden fields keep their React Hook Form values.

These properties control the UI only. They do not change the schema or remove values from submission.

Node property reference

These properties apply to more than one node type:

PropertyNode typesPurpose
idAllIdentifies the node. It is required for sections and render nodes.
visibleAllShows or hides the node. The default is true.
disabledAllMarks the node disabled. The default is false.
readOnlyAllMarks the node read-only. The default is false.
classNameField, section, arrayAdds a class to the structural root.
spanField, section, arraySets the node width in its parent grid.
slotOptionsField, section, arrayPasses typed options to the matching slot.

Field properties:

PropertyRequiredPurpose
kind: "field"YesSelects a field node.
pathYesSelects a path in the current schema scope.
controlYesSelects a compatible registered control.
labelNoSupplies string or React label content.
descriptionNoSupplies string or React help content.
propsControl-dependentSupplies application-owned props to the selected control.
optionsOption-capable controlsSupplies a static or asynchronously loaded selectable list.
requiredNoPasses UI requirement state to the control.

Section properties:

PropertyRequiredPurpose
kind: "section"YesSelects a section node.
idYesGives the section a stable identifier.
childrenYesContains nodes in the same path scope.
titleNoSupplies string or React heading content.
descriptionNoSupplies string or React help content.
columnsNoSets the child grid. The default is 1.

Array properties:

PropertyRequiredPurpose
kind: "array"YesSelects an array node.
pathYesSelects an array path in the current schema scope.
itemDefaultYesSupplies one complete item for the add action.
childrenYesContains nodes relative to one array item.
labelNoSupplies string or React label content.
descriptionNoSupplies string or React help content.

Render properties:

PropertyRequiredPurpose
kind: "render"YesSelects a render node.
idYesGives the render node a stable identifier.
componentYesSupplies the React component to render.

label, description, title, props, slotOptions, visible, disabled, readOnly, required, className, columns, and span can use resolvers where their node type supports the property.

See Form kits for control props, selectable options, slot options, and custom slot examples.

IDs and paths

Sections and render nodes require an explicit id. Fields and arrays derive an ID from their path when you omit id. IDs must be non-empty and unique in the same path scope.

Use RHF dot syntax for both object properties and array indexes:

{ kind: "field", path: "profile.email", control: "text" }
{ kind: "field", path: "speakers.0.name", control: "text" }

Do not put a root path in an array child. Use the path relative to one item:

{
	kind: "array",
	path: "speakers",
	itemDefault: { name: "" },
	children: [
		// Correct: resolves to speakers.0.name for the first item.
		{ kind: "field", path: "name", control: "text" },
	],
}

Form-local React content

A render node inserts application content into the generated tree. Its component receives the resolved inherited disabled and readOnly flags.

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>

A render node also accepts resolver-based visible, disabled, and readOnly. It requires an explicit id and a component.

Use direct RHF composition outside kit.Fields when content needs the form API. Render nodes deliberately receive only interaction state.

Definition lifetime

Keep a definition stable for the complete kit.useForm hook lifetime. Define it at module scope when possible.

const definition = kit.defineForm(schema, { ui })
 
function Editor() {
	const form = kit.useForm(definition, { defaultValues })
	return <kit.AutoForm form={form} />
}

Passing a different definition to an existing hook does not replace its first definition. When the definition must change, remount the component with a React key.