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:
| Property | Result | Default |
|---|---|---|
visible | Shows or hides the field. | true |
disabled, readOnly | Changes the control interaction state. | false |
required | Passes required UI state to the control. | false |
label, description | Changes displayed content. | No content |
props, slotOptions | Changes control props or slot configuration. | Control-dependent |
whenHidden | Keeps, 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,
])accountBasecontains the fields that all variants share.- Each variant extends
accountBasewith its own fields. - The discriminator
accountTypehas a different literal value in each variant. .prefault("")validates a missing value as an empty string. The fields of the other variant are not indefaultValues, so they areundefinedafter a switch. Without.prefault(""), the schema showsInvalid input: expected string, received undefinedinstead of your messages. Do not use.default(""), because it skips the.min(1)rule.defaultValuesneed 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
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
accountTypefield selects the variant. Theradiocontrol writes one of the literal values. - Each section has one
visibleresolver. The variant fields do not need resolvers, so they use a staticrequired: 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.
| Concern | One object with optional fields | Discriminated union |
|---|---|---|
| Schema | Optional fields and a superRefine rule for each condition. | One object schema for each variant. |
| Definition | visible and required resolvers on each variant field. | One visible resolver on each variant section. |
| Output type | All variant fields are optional. | A union that accountType narrows. |
| Values of the other variant | A 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
| Value | Result |
|---|---|
"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.updatein the code that changes the context. - History or persistence restores values.
beforeUpdatecancels 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:
| Rule | Schema tool | Example |
|---|---|---|
| A value selects which fields exist. | z.discriminatedUnion | Split a form into variants |
| One condition relates two or more fields. | .refine with path | The approval note below |
| One rule adds more than one issue. | .superRefine | A 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.