Skip to content

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:

Condition

A 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.

Validator

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 },
  ],
}
Why visibleWhen is excluded

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

Known limitation

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 },
    },
  ],
}
Hard error

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.