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
pathexists 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.
columnsandspanuse 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:
| Property | Node types | Purpose |
|---|---|---|
id | All | Identifies the node. It is required for sections and render nodes. |
visible | All | Shows or hides the node. The default is true. |
disabled | All | Marks the node disabled. The default is false. |
readOnly | All | Marks the node read-only. The default is false. |
className | Field, section, array | Adds a class to the structural root. |
span | Field, section, array | Sets the node width in its parent grid. |
slotOptions | Field, section, array | Passes typed options to the matching slot. |
Field properties:
| Property | Required | Purpose |
|---|---|---|
kind: "field" | Yes | Selects a field node. |
path | Yes | Selects a path in the current schema scope. |
control | Yes | Selects a compatible registered control. |
label | No | Supplies string or React label content. |
description | No | Supplies string or React help content. |
props | Control-dependent | Supplies application-owned props to the selected control. |
options | Option-capable controls | Supplies a static or asynchronously loaded selectable list. |
required | No | Passes UI requirement state to the control. |
Section properties:
| Property | Required | Purpose |
|---|---|---|
kind: "section" | Yes | Selects a section node. |
id | Yes | Gives the section a stable identifier. |
children | Yes | Contains nodes in the same path scope. |
title | No | Supplies string or React heading content. |
description | No | Supplies string or React help content. |
columns | No | Sets the child grid. The default is 1. |
Array properties:
| Property | Required | Purpose |
|---|---|---|
kind: "array" | Yes | Selects an array node. |
path | Yes | Selects an array path in the current schema scope. |
itemDefault | Yes | Supplies one complete item for the add action. |
children | Yes | Contains nodes relative to one array item. |
label | No | Supplies string or React label content. |
description | No | Supplies string or React help content. |
Render properties:
| Property | Required | Purpose |
|---|---|---|
kind: "render" | Yes | Selects a render node. |
id | Yes | Gives the render node a stable identifier. |
component | Yes | Supplies 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.