Skip to content
Form, Please

Conditional fields

Use a synchronous resolver when a UI property depends on form values or runtime context. A resolver returns the property value for the current form state.

Show or hide a field

Set visible to a resolver that returns a boolean:

				{
					kind: "field",
					path: "companyName",
					control: "text",
					label: "Company name",
					required: ({ accountType }) => accountType === "company",
					visible: ({ accountType }) => accountType === "company",
					props: {
						placeholder: "Compiler Labs",
						autoComplete: "organization",
					},
				},

When accountType changes, "Form, Please" resolves the UI tree again. The companyName field appears only for a company account.

visible has these effects:

  • A hidden node does not render.
  • A hidden section or array does not render its descendants.
  • A hidden field keeps its value in React Hook Form state.
  • The Standard Schema still receives the hidden value during submission.

visible does not clear a value or change schema validation.

Derive field properties

Field properties can use the same form values:

		{
			kind: "field",
			path: "address",
			control: "text",
			label: ({ delivery }) => {
				if (delivery === "shipping") return "Shipping address"
				return "Address"
			},
			description: ({ locked }) => {
				if (locked) return "Unlock the order to edit this address."
				return "Enter the complete shipping address."
			},
			visible: ({ delivery }) => delivery === "shipping",
			readOnly: ({ locked }) => locked,
			required: ({ delivery }) => delivery === "shipping",
			props: ({ delivery }) => {
				if (delivery === "shipping") {
					return { placeholder: "12 Analytical Engine Way" }
				}
				return {}
			},
		},

On an ordinary definition node, the first resolver argument is the complete, deeply readonly schema input. A resolver authored inside a reusable fragment receives that fragment's local input instead. It is not only the value at the field path.

These field properties accept a static value or a synchronous resolver:

PropertyResultDefault
visibleShows or hides the field.true
disabled, readOnlyChanges the control interaction state.false
requiredPasses required UI state to the control.false
label, descriptionChanges displayed content.No content
props, slotOptionsChanges control props or slot configuration.Control-dependent

Layout properties such as className and span also accept resolvers. See Definitions for the complete property reference.

Ordinary resolvers must return immediately. A resolver that returns a promise causes a runtime error. Keep resolvers small and free of side effects. Do not fetch data or update form state in an ordinary resolver.

A selectable field's options property has a separate function contract. It may return a promise and tracks the values and context properties it reads. See Async options.

Apply one condition to a group

Put visible, disabled, or readOnly on a section or array when one condition applies to all descendants:

const projectDefinition = nativeFormKit.defineForm(projectSchema, {
	ui: [
		{
			kind: "section",
			id: "project-details",
			title: "Project details",
			readOnly: ({ archived }) => archived,
			children: [
				{
					kind: "field",
					path: "details.name",
					control: "text",
					label: "Name",
				},
				{
					kind: "field",
					path: "details.summary",
					control: "text",
					label: "Summary",
				},
			],
		},
	],
})

A descendant cannot override inherited state:

  • A hidden parent hides all descendants.
  • A disabled parent disables all descendants.
  • A read-only parent makes all descendants read-only.

required is a field property. It is not inherited from a section or array.

Use runtime context

Use context for data that is not part of the editable form value. Examples include permissions, feature flags, and loaded option lists.

Bind the context type once with kit.forContext<Context>():

const editorDefinition = editorKit.defineForm(editorSchema, {
	ui: [
		{
			kind: "field",
			path: "country",
			control: "select",
			label: "Country",
			disabled: (_values, { context }) => !context.canEdit,
			options: ({ context }) => context.countries,
		},
	],
})
 
function Editor({ context }: { readonly context: EditorContext }) {
	const form = editorKit.useForm(editorDefinition, {
		defaultValues: { country: "" },
		context,
	})
 
	return <editorKit.AutoForm form={form} />
}

The second resolver argument contains the current, deeply readonly context. Pass a concrete context value to useForm.

Keep requests and cache state in the application. Pass their current state through context. See Resource state for fromResource examples.

Use resolvers inside arrays carefully

An array-child resolver receives the complete root input. It does not receive the current row or its index. Therefore, the same resolver result applies to each generated row for a given form state.

Use an array-node resolver for conditions that depend on the complete array:

const contactsDefinition = nativeFormKit.defineForm(contactsSchema, {
	ui: [
		{
			kind: "array",
			path: "contacts",
			label: ({ contacts }) => `Contacts (${contacts.length})`,
			itemDefault: { email: "" },
			children: [
				{
					kind: "field",
					path: "email",
					control: "text",
					label: "Email",
					props: { type: "email" },
				},
			],
		},
	],
})

Keep row and cross-row validation in the Standard Schema. Use custom array UI when the UI condition must depend on the current row index. See Arrays for custom operations and array slots.

Keep conditional validation in the schema

visible and required control the rendered UI. They do not add, remove, or change schema rules.

Express the same condition in the Standard Schema:

const accountSchema = z
	.object({
		accountType: z.enum(["personal", "company"]),
		companyName: z.string().optional(),
	})
	.superRefine((value, context) => {
		if (
			value.accountType === "company" &&
			(value.companyName ?? "").trim().length === 0
		) {
			context.addIssue({
				code: "custom",
				message: "Company name is required",
				path: ["companyName"],
			})
		}
	})
	.transform(({ companyName, ...account }) => {
		if (account.accountType === "personal") return account
		return { ...account, companyName: companyName?.trim() }
	})

This schema requires companyName only for a company account. Its transform also removes a stale companyName from personal-account output.

Use a schema transform when cleanup is part of the submission contract. Use value middleware when the source and cleared value must commit atomically. Use form.update for the same managed boundary in an application action. See Value middleware.