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:

			ui.field("companyName", {
				control: "text",
				label: "Company name",
				required: true,
				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, and the schema still validates it.

See Hidden fields keep their values.

Derive field properties

Field properties can use the same form values:

	ui.field("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
whenHiddenKeeps, resets, or replaces the value when an edit hides the field."keep"

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) => [
	ui.section("project-details", {
		title: "Project details",
		readOnly: ({ archived }) => archived,
		children: [
			ui.field("details.name", {
				control: "text",
				label: "Name",
			}),
			ui.field("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) => [
	ui.field("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) => [
	ui.array("contacts", {
		label: ({ contacts }) => `Contacts (${contacts.length})`,
		itemDefault: { email: "" },
		children: (contact) => [
			contact.field("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.

Split a form into variants

Some forms have variants with different fields. A personal account needs a first name and a last name. A company account needs a company name and a VAT ID. One flat schema with optional fields mixes the two variants. Each field then needs its own visible and required resolvers, and all variant fields are optional in the output type.

Write one object schema for each variant. Join the variants with z.discriminatedUnion:

const accountBase = z.object({
	email: z.email("Enter a valid email"),
})
 
const personalAccount = accountBase.extend({
	accountType: z.literal("personal"),
	firstName: z.string().min(1, "Enter your first name").prefault(""),
	lastName: z.string().min(1, "Enter your last name").prefault(""),
})
 
const companyAccount = accountBase.extend({
	accountType: z.literal("company"),
	companyName: z.string().min(1, "Enter the company name").prefault(""),
	vatId: z.string().min(1, "Enter the VAT ID").prefault(""),
})
 
const accountSchema = z.discriminatedUnion("accountType", [
	personalAccount,
	companyAccount,
])
  • accountBase contains the fields that all variants share.
  • Each variant extends accountBase with its own fields.
  • The discriminator accountType has a different literal value in each variant.
  • .prefault("") validates a missing value as an empty string. The fields of the other variant are not in defaultValues, so they are undefined after a switch. Without .prefault(""), the schema shows Invalid input: expected string, received undefined instead of your messages. Do not use .default(""), because it skips the .min(1) rule.
  • defaultValues need only the shared fields and the discriminator, such as { accountType: "personal", email: "" }.

Put the fields of each variant in one section. Show the section only when the discriminator selects its variant:

const accountDefinition = kit.defineForm(accountSchema, (ui) => [
	ui.field("accountType", {
		control: "radio",
		label: "Account type",
		options: [
			{ value: "personal", label: "Personal" },
			{ value: "company", label: "Company" },
		],
	}),
	ui.section("personal-account", {
		title: "Personal details",
		columns: 2,
		visible: ({ accountType }) => accountType === "personal",
		children: [
			ui.field("firstName", {
				control: "text",
				label: "First name",
				required: true,
			}),
			ui.field("lastName", {
				control: "text",
				label: "Last name",
				required: true,
			}),
		],
	}),
	ui.section("company-account", {
		title: "Company details",
		columns: 2,
		visible: ({ accountType }) => accountType === "company",
		children: [
			ui.field("companyName", {
				control: "text",
				label: "Company name",
				required: true,
			}),
			ui.field("vatId", {
				control: "text",
				label: "VAT ID",
				required: true,
			}),
		],
	}),
	ui.field("email", {
		control: "text",
		label: "Email",
		required: true,
		props: { type: "email" },
	}),
])

Live result

Personal details

Submit the form to see the schema output.

Select Company and submit the empty form. The form shows issues only for the company fields and the email. Complete the fields and submit again. The output contains only the company variant.

The definition has these parts:

  • The accountType field selects the variant. The radio control writes one of the literal values.
  • Each section has one visible resolver. The variant fields do not need resolvers, so they use a static required: true.

Hidden fields keep their values. See Hidden fields keep their values. The schema validates only the selected variant. z.object removes unknown keys by default, so the output does not contain the fields of the other variant. Do not use z.looseObject for a variant, because it keeps those keys.

onSubmit receives a union value. Compare value.accountType to narrow it to one variant. Resolvers receive the same union input, and the same comparison narrows it.

ConcernOne object with optional fieldsDiscriminated union
SchemaOptional fields and a superRefine rule for each condition.One object schema for each variant.
Definitionvisible and required resolvers on each variant field.One visible resolver on each variant section.
Output typeAll variant fields are optional.A union that accountType narrows.
Values of the other variantA transform must remove them.The selected variant schema removes them.

The union can also be at a nested path. Then field paths include the parent key, such as account.companyName. A fragment for one variant cannot be placed at a union scope, because the scope does not satisfy the input of that variant. Put the variant fields directly in the section, or define the fragment with the complete union schema.

Reset a hidden field

A hidden field keeps its value. When the field becomes visible again, the user sees the old value. Set whenHidden to clear the value when an edit hides the field:

const accountDefinition = kit.defineForm(accountSchema, (ui) => [
	ui.field("accountType", {
		control: "radio",
		label: "Account type",
		options: accountTypes,
	}),
	ui.field("companyName", {
		control: "text",
		label: "Company name",
		visible: (values) => values.accountType === "company",
		whenHidden: "reset",
	}),
])

Live result

Submit the form to see the schema output.
ValueResult
"keep"The field keeps its value. This is the default.
"reset"The field receives its value from defaultValues.
{ value }The field receives value. Use it to write the strings "keep" or "reset".

whenHidden also accepts a resolver. The resolver receives the values after the edit and the runtime context:

const editorDefinition = editorKit.defineForm(accountSchema, (ui) => [
	ui.field("accountType", {
		control: "radio",
		label: "Account type",
		options: accountTypes,
	}),
	ui.field("companyName", {
		control: "text",
		label: "Company name",
		visible: (values) => values.accountType === "company",
		whenHidden: (_values, { context }) => {
			if (context.mode !== "create") return
			return "reset"
		},
	}),
])

A resolver that returns undefined keeps the value. Do not repeat the visible condition in this resolver. The form calls it only when the field becomes hidden.

The form applies whenHidden in the same managed update as the edit. Value middleware, history, and persistence receive one transaction. One undo restores the edit and the reset value.

The form does not apply whenHidden in these cases:

  • The field is hidden when the form mounts.
  • The field was hidden before the edit.
  • A context change hides the field. Call form.update in the code that changes the context.
  • History or persistence restores values.
  • beforeUpdate cancels the edit.

whenHidden does not change submission or validation. The key stays in the values, and the schema still validates it. To remove the key from the output, use a schema transform.

In an array item, "reset" needs a value at the same path in defaultValues. An appended item has no such value, so the form throws a TypeError. Use { value } for fields in array items. A structural array edit, such as append, remove, or move, does not apply whenHidden to the fields of that array.

Keep conditional validation in the schema

visible and required do not change schema rules. See required is UI-only.

Select the schema tool that matches the rule:

RuleSchema toolExample
A value selects which fields exist.z.discriminatedUnionSplit a form into variants
One condition relates two or more fields..refine with pathThe approval note below
One rule adds more than one issue..superRefineA unique email in each array row

A boolean can also select a variant. Put the checkbox and its dependent fields in one nested object, such as { enabled, from }. Then use enabled as the discriminator with z.literal(true) and z.literal(false).

Use .refine when a range or a comparison controls the rule. This form shows the approval note only for a discount above 20%. The schema requires the note for the same condition:

const discountSchema = z
	.object({
		discountPercent: z.number().min(0).max(100),
		approvalNote: z.string(),
	})
	.refine(
		({ discountPercent, approvalNote }) =>
			discountPercent <= 20 || approvalNote.trim() !== "",
		{ message: "Explain a discount above 20%", path: ["approvalNote"] },
	)
 
const discountDefinition = nativeFormKit.defineForm(discountSchema, (ui) => [
	ui.field("discountPercent", {
		control: "number",
		label: "Discount percent",
	}),
	ui.field("approvalNote", {
		control: "textarea",
		label: "Approval note",
		required: true,
		visible: ({ discountPercent }) => discountPercent > 20,
	}),
])

path attaches the issue to the approvalNote field. Without path, the issue is a form-level issue.

Use a schema transform when cleanup is part of the submission contract. Use whenHidden to clear the editable value when an edit hides the field. Use value middleware when the cleared value depends on more than visibility. Use form.update for the same managed boundary in an application action. See Value middleware.

Next steps