Conditions
Any field can react to another field's value — or, for disabling, another field's validity — via visibleWhen, disabledWhen, and enabledWhen. All three take the same ConditionSpec shape; what differs is what happens when the condition matches.
visibleWhen, disabledWhen, enabledWhen
visibleWhen controls whether the field renders at all. A field whose visibleWhendoes not match is excluded from the validation schema entirely — the condition-aware resolver only validates currently-visible fields — and its value is stripped from the submit payload (the resolver's schema runs in Zod strip mode). It is not just hidden CSS: an invisible required field cannot block submit.
disabledWhen and enabledWhen never affect rendering or validation — the field stays in the schema and in the payload, it just gets the HTML disabled attribute. enabledWhen is the inverse of disabledWhen (disabled while the spec does notmatch) — it exists so you can write "enabled once X" directly instead of negating every leaf. A field may set one or the other, not both; the config validator rejects a field with both.
{
type: "text",
name: "companyName",
label: "Company name",
required: true,
visibleWhen: { field: "accountType", equals: "company" },
}Condition shape
A single condition is { field, equals?, notEquals?, in?, isValid? } — those four are the complete operator list (the validator rejects a condition with none of them set). A ConditionSpec is one of three shapes, all evaluating to the same normalized form internally:
ConditionA single condition — the object above, on its own.
Condition[]An AND-list; every entry must match.
// Condition[] — every entry must match (AND) visibleWhen: [ { field: "country", equals: "US" }, { field: "accountType", notEquals: "individual" }, ]{ anyOf: Condition[][] }OR of AND-groups (DNF: disjunctive normal form). Any group matching is enough.
// { anyOf: Condition[][] } — OR of AND-groups (DNF) visibleWhen: { anyOf: [ [{ field: "plan", equals: "pro" }], [{ field: "plan", equals: "enterprise" }, { field: "seats", in: [10, 25, 50] }], ], }
The flat two-level shape (groups OR together, conditions AND within a group) is deliberate — any boolean combination is expressible this way without a recursive tree, which keeps evaluation a one-liner (groups.some(g => g.every(match))) and keeps a future builder UI flat instead of recursive.
An empty spec ([] or { anyOf: [] }) is rejected rather than silently treated as always-matching, since for disabledWhen that would mean permanently disabled.
The isValid operator
isValid matches when a source field's own Zod schema passes (or fails, for isValid: false) against its current value — computed by safe-parsing the field's schema directly, not by reading React Hook Form's formState.errors (which only exist after validation has run and depend on validation mode/timing).
{
type: "email",
name: "email",
label: "Email",
// Disabled until BOTH sibling fields pass their own zod schema.
enabledWhen: [
{ field: "firstName", isValid: true },
{ field: "lastName", isValid: true },
],
}isValid is only allowed in disabledWhen and enabledWhen — the config validator rejects it in visibleWhen. The reason is structural: visibility drives which fields are in the validation schema, so a field whose visibility depended on another field's validity could create a feedback loop (or make payload stripping depend on validity that itself depends on visibility). Disabled fields stay in the schema either way, so validity-driven disabling has no such loop.
The oracle parses each field's schema in isolation. Cross-field rules — rules.matches, date/time sibling bounds (minDateField/maxDateField), optionsFrom branch membership — live in the form-level superRefine, not on the field's own schema, precisely so they can see other fields' values. That also means isValidagainst a field with only cross-field rules will not reflect those rules — it only sees what that field's own schema checks.
Known limitation: conditions inside groups
visibleWhen on a field nested inside a group field is not skipped by validation in v1 — pinned by a test (not a bug waiting to be fixed silently under you). A required inner field hidden by its own visibleWhen still blocks submit.
{
type: "group",
name: "team",
min: 1,
fields: [
{ type: "checkbox", name: "hasRole" },
{
type: "text",
name: "role",
required: true,
// Hidden in the UI when hasRole is false — but still validated as
// required. Submitting an empty row with hasRole unchecked fails.
visibleWhen: { field: "hasRole", equals: true },
},
],
}An isValid condition targeting a group-nested field is rejected outright by the config validator (not a silent limitation) — the per-field schema map the oracle uses only holds top-level fields, the same reason group-nested otp dependsOn wiring is rejected.
Try it
Pick "Company" below and the company name field appears — and becomes part of what submit validates.
View config
{
"id": "conditions-demo",
"fields": [
{
"type": "select",
"name": "accountType",
"label": "Account type",
"required": true,
"options": [
{
"label": "Individual",
"value": "individual"
},
{
"label": "Company",
"value": "company"
}
]
},
{
"type": "text",
"name": "companyName",
"label": "Company name",
"required": true,
"visibleWhen": {
"field": "accountType",
"equals": "company"
}
},
{
"type": "submit",
"name": "submit",
"text": "Continue"
}
]
}For a fuller demo combining visibleWhen, optionsFrom, and phone countryFrom in one form, see the conditional profile example. For conditions on entire wizard steps, see Multi-step wizards.