Conditional Logic
Drive visible, disabled, required, and options from other field values with predicate functions.
A field's behaviour doesn't have to be fixed at config time. Four config keys —
visible, disabled, required, and (for choice fields) options — accept a
predicate function that receives the current form values and returns the
resolved value. When any sibling field changes, predicate-bearing fields
re-evaluate and re-render.
vat_number: {
type: "text",
label: "VAT number",
visible: (values) => values.kind === "business",
}This is the whole conditional-logic surface: there is no separate "rules" or
"dependsOn" config. You read whatever sibling values you need inside the
predicate and return a boolean (or, for options, an array of options).
The four predicate-capable keys
Each key accepts either a static value or a FieldValuesPredicate — a function
of the form (values) => result. The predicate receives the full form values
map (typed as Record<string, unknown>), so it can read any sibling field;
narrow with a cast or guard at the call site.
| Key | Static type | Predicate type | Default when omitted | Effect when resolved |
|---|---|---|---|---|
visible | boolean | (values) => boolean | true | false unmounts the field |
disabled | boolean | (values) => boolean | false | true renders the input disabled |
required | boolean | (values) => boolean | false | true adds the * indicator and aria-required |
options | SelectOption[] | (values) => SelectOption[] | — (required on choice fields) | recomputes the choice set |
visible, disabled, and required live on every field via FieldConfigBase.
options exists only on the choice field types — select, radio,
combobox, and checkboxGroup — where its predicate form is typed as
FieldValuesPredicate<SelectOption[]>.
The values argument is the same object for every predicate on the form — the
complete values map, not just this field's value. Reading values.kind inside
the vat_number field's predicate is exactly how cross-field logic works.
How resolution works
Each predicate prop collapses to its resolved value through one rule:
- a static value is returned as-is;
- a function is invoked with the current values;
undefinedfalls back to the per-key default (visible→true,disabledandrequired→false).
Only fields that carry at least one function-form predicate subscribe to the form values store. A field whose config is entirely static keeps its isolated, per-field re-render behaviour and is not re-rendered when an unrelated field changes. This keeps the common case cheap: adding a predicate to one field does not make every other field reactive.
Because predicate fields subscribe to the whole values object, a conditional field re-renders once per change to any field while the predicate is present. This is by design — the subscription is the full values map, not a declared dependency slice. It matters only on very large forms with many conditional fields.
Visibility: show and hide fields
A visible predicate that returns false unmounts the field — it is removed
from the DOM, not hidden with display: none. The field's value is retained
in form state, so toggling the predicate back to true restores whatever the
user had typed.
This example (from the conditionals playground) reveals a VAT field only for business customers:
const visibilitySchema = z.object({
kind: z.enum(["personal", "business"]),
name: z.string().min(1, "Name is required"),
vat_number: z.string(),
});
const visibilityFields: FormFields<typeof visibilitySchema> = {
kind: {
type: "radio",
label: "Customer kind",
options: [
{label: "Personal", value: "personal"},
{label: "Business", value: "business"},
],
},
name: {type: "text", label: "Name", required: true},
vat_number: {
type: "text",
label: "VAT number",
placeholder: "PT123456789",
visible: (values) => values.kind === "business",
},
};Hidden fields are still validated
A field hidden by a false visible predicate keeps its value in form state
and is still validated on submit. Predicate visibility does not skip
validation. A z.string().min(1) field that is currently unmounted but holds an
empty stored value will fail submit-time validation — and because the field is
unmounted, there is no visible affordance to show the error, so the Submit
button appears inert.
The schema branch for the hidden case must permit the stored value. Make it
.optional() / .nullable() where the branch genuinely has no value, or model
the two states with z.discriminatedUnion for branch-conditional requirements.
In the example above, vat_number is declared z.string() (not
z.string().min(1)), so a hidden, empty VAT number validates cleanly. If
business customers must supply a VAT number, encode that with a discriminated
union rather than a bare .min(1):
const schema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("personal"),
vat_number: z.string().optional(),
}),
z.object({
kind: z.literal("business"),
vat_number: z.string().min(1, "VAT number is required"),
}),
]);Disabled and required
disabled and required never unmount the field — they flip flags reactively.
A common pairing is an opt-in checkbox that drives a dependent field:
const disabledRequiredFields: FormFields<typeof schema> = {
newsletter: {
type: "checkbox",
label: "Subscribe to the newsletter",
},
newsletter_email: {
type: "text",
label: "Newsletter email",
placeholder: "name@example.com",
disabled: (values) => values.newsletter !== true,
required: (values) => values.newsletter === true,
},
};required is a UI hint only
The required flag is presentational. When it resolves to true the field
shell renders an asterisk next to the label and sets aria-required on the
input:
- the asterisk is
aria-hidden="true"and styledtext-destructive— it is a visual cue only and is not announced to screen readers; aria-required="true"is set on the input itself, which is what assistive technology reads.
required does not enforce anything. Hard validation must live in the Zod
schema. A reactive required: (values) => … predicate changes only the
asterisk and aria-required; to actually reject an empty value, encode the
requirement in the schema (with .refine or a discriminatedUnion for the
conditional case).
Dependent options
On choice fields, options accepts a predicate that recomputes the available
choices from current values. Here the priority scale changes with the selected
region:
const dependentOptionsFields: FormFields<typeof schema> = {
region: {
type: "select",
label: "Region",
options: [
{label: "EU", value: "EU"},
{label: "Worldwide", value: "WORLDWIDE"},
],
},
priority: {
type: "radio",
label: "Priority",
options: (values) =>
values.region === "EU"
? [
{label: "Standard", value: "standard"},
{label: "Express", value: "express"},
]
: [
{label: "Economy", value: "economy"},
{label: "Standard", value: "standard"},
{label: "Express", value: "express"},
],
},
};Recomputing options does not auto-clear a now-invalid selection. If the
user picked economy for a worldwide form and then switched the region to EU
(where economy is no longer offered), the stored value stays economy.
Reconciling a stale selection against the new option set is the consumer's
responsibility — reset it in an effect or validate it in the schema.
Predicates span the whole form
Predicates resolve against the single shared form store, so a dependency does
not have to be visually adjacent to the field that reacts to it. A field in one
tab can react to a field in another tab — both read and write the same store
through <form.Provider>.
const crossTabFields: FormFields<typeof schema> = {
company_name: {type: "text", label: "Company name"},
tax_residency: {
type: "text",
label: "Tax residency",
visible: (values) =>
typeof values.company_name === "string" &&
values.company_name.length > 0,
},
};When company_name changes on the Company tab, the tax_residency field on the
Tax tab re-evaluates its visible predicate — no manual wiring between the tabs
is needed.
Predicates inside array items
The array field type takes an item config describing one row. Item configs
are typed as StaticFormFields, which excludes the function form of
visible, disabled, required, and options. A predicate inside item is a
compile-time error, not a silent runtime no-op — the array primitive does not
subscribe to values per row.
Field-level error and validator paths inside arrays use bracket notation against
the row index: a child field on the first row resolves under parent[0].child.
Keep that in mind when writing schema messages or validate codes for array
items.
Use static booleans and static option arrays inside item configs; if a row
field genuinely needs to react to sibling values, model it at the top level
instead of nesting it in an array.