Conditional Logic

Control field visibility, disabled state, and read-only mode with declarative conditions or functions.

Conditional Logic

Every field supports three state properties that control its behavior based on other field values: hidden, disabled, and readOnly. Each accepts a boolean, a function, a declarative ConditionGroup, or (for hidden only) responsive breakpoint visibility.


Quick Overview

FormBuilder.create('example')
  .addField('plan', (f) =>
    f
      .type('select')
      .label('Plan')
      .options([
        { label: 'Free', value: 'free' },
        { label: 'Pro', value: 'pro' },
      ])
      .required(),
  )
  .addField('billing', (f) =>
    f
      .type('select')
      .label('Billing Cycle')
      .options([
        { label: 'Monthly', value: 'monthly' },
        { label: 'Yearly', value: 'yearly' },
      ])
      .hidden({
        conditions: [{ field: 'plan', operator: 'equals', value: 'free' }],
        operator: 'AND',
      })
      .optional(), // conditionally hidden — keep optional (see below)
  )
  .addField('coupon', (f) =>
    f
      .type('text')
      .label('Coupon Code')
      .disabled((values) => values.plan === 'free')
      .optional(),
  )
  .addStep('main', ['plan', 'billing', 'coupon'])
  .build();

State Properties

hidden

Controls whether a field is rendered. Hiding is render-only: a hidden field renders nothing, but it stays registered in form state and in the validation schema — its value is still validated on step navigation/submit and is included in the submitted payload.

Hidden ≠ excluded. Because hidden fields are still validated, don’t mark a conditionally hidden field as .required() — when the condition hides it, the empty value fails validation and blocks submission with an error the user can’t see. Keep such fields optional, or give them a schema that accepts an empty value.

// Static boolean
.addField('notes', (f) => f.type('textarea').hidden(true).optional())

// Function — receives all form values
.addField('company', (f) => f.type('text').label('Company').optional()
  .hidden((values) => values.accountType !== 'business')
)

// Declarative ConditionGroup
.addField('company', (f) => f.type('text').label('Company').optional()
  .hidden({
    conditions: [{ field: 'accountType', operator: 'notEquals', value: 'business' }],
    operator: 'AND',
  })
)

// Responsive breakpoint visibility (CSS classes — see Responsive Visibility below)
.addField('mobileHint', (f) => f.type('html').optional().hidden({
  default: 'visible',
  lg: 'hidden',
}))

disabled

Prevents interaction with the field. The field remains visible and its value is still submitted.

// Static boolean
.addField('email', (f) => f.type('email').disabled(true).optional())

// Function
.addField('email', (f) => f.type('email').optional()
  .disabled((values) => !!values.useSameEmail)
)

// Declarative ConditionGroup
.addField('email', (f) => f.type('email').optional()
  .disabled({
    conditions: [{ field: 'useSameEmail', operator: 'isTrue' }],
    operator: 'AND',
  })
)

readOnly

Like disabled, but with read-only styling. The field value is submitted but the user cannot modify it.

Caveat for selection/toggle fields: select, checkbox, switch, and radio remove their change handler whenever readOnly is set at all — passing a function or ConditionGroup to these types makes them permanently read-only, regardless of how the condition evaluates. Use disabled for dynamic locking on those types.

// Static boolean
.addField('id', (f) => f.type('text').readOnly(true).optional())

// Function
.addField('total', (f) => f.type('text').optional()
  .readOnly((values) => values.status === 'confirmed')
)

// Declarative ConditionGroup
.addField('total', (f) => f.type('text').optional()
  .readOnly({
    conditions: [{ field: 'status', operator: 'equals', value: 'confirmed' }],
    operator: 'AND',
  })
)

Condition Operators

The ConditionGroup system uses 12 operators for declarative conditions:

OperatorDescriptionValue Required
equalsStrict equality (===)Yes
notEqualsStrict inequality (!==)Yes
containsString/array includes valueYes
notContainsString/array does not include valueYes
greaterThanNumeric >Yes
lessThanNumeric <Yes
greaterThanOrEqualNumeric >=Yes
lessThanOrEqualNumeric <=Yes
isTrueValue is exactly true (strict === true)No
isFalseValue is exactly false (strict === false)No
isEmptyValue is falsy ('', null, undefined, 0, false), a whitespace-only string, or an empty arrayNo
isNotEmptyValue is not null/undefined, not a blank string, and not an empty arrayNo

isTrue and isFalse are strict checks in field conditions and step routing. Submit-action conditions use a looser evaluator: isTrue also accepts 'true' and 1, and isFalse also accepts 'false' and 0.


ConditionGroup

A ConditionGroup combines multiple conditions with AND or OR logic:

type ConditionGroup = {
  conditions: Condition[];
  operator: 'AND' | 'OR';
};

type Condition = {
  field: string; // Field name to watch
  operator: ConditionOperator;
  value?: string | number | boolean | null;
};

AND — All conditions must be true

.hidden({
  conditions: [
    { field: 'country', operator: 'equals', value: 'US' },
    { field: 'state', operator: 'isNotEmpty' },
  ],
  operator: 'AND',
})

OR — At least one condition must be true

.disabled({
  conditions: [
    { field: 'status', operator: 'equals', value: 'locked' },
    { field: 'role', operator: 'equals', value: 'viewer' },
  ],
  operator: 'OR',
})

Responsive Visibility

The hidden property has a special responsive mode using Tailwind breakpoints. Visibility is applied with CSS classes (hidden/block) on the field wrapper, so the field stays mounted — it is still validated and its value is still submitted at every screen size.

.addField('mobileHint', (f) => f.type('html')
  .value('<p>Swipe left to see more</p>')
  .optional()
  .hidden({
    default: 'visible', // Visible on mobile
    lg: 'hidden',       // Hidden on lg+ screens
  })
)

Available breakpoints: default, sm, md, lg, xl, 2xl

Each breakpoint accepts 'visible' or 'hidden'. Breakpoints cascade upward — setting md: 'hidden' keeps the field hidden on md, lg, xl, and 2xl unless overridden.

default: 'hidden' unmounts the field. When default is 'hidden', the renderer removes the field entirely (it renders null at every screen size) instead of hiding it with CSS — so a combination like { default: 'hidden', lg: 'visible' } will not show the field on large screens. Start from default: 'visible' (or omit default) and hide at specific breakpoints.


Function vs Declarative

ApproachSerializableUse Case
booleanYesStatic, never changes
ConditionGroupYesPure JSON — storable in a database/API, usable by visual form-builder tooling
(values) => booleanNoComplex logic the 12 operators can’t express

Use ConditionGroup when the form configuration is serialized — loaded from a database or API, or produced by visual form-builder tooling — it’s pure JSON and can be stored and transmitted.

Use functions when you need synchronous logic that can’t be expressed with the 12 operators — regex matching, numeric math, or combining values from multiple fields in complex ways.

Conditions control a field’s state (hidden/disabled/readOnly). To derive a field’s value from other fields, use computed fields instead — see useComputedFields.


Complete Example

const config = FormBuilder.create('registration')
  .addField('accountType', (f) =>
    f
      .type('button-radio')
      .label('Account Type')
      .options([
        { label: 'Personal', value: 'personal' },
        { label: 'Business', value: 'business' },
      ])
      .required(),
  )
  .addField('name', (f) => f.type('text').label('Full Name').required())
  .addField('company', (f) =>
    f
      .type('text')
      .label('Company Name')
      // Optional on purpose: hidden fields are still validated,
      // so a required field hidden by a condition would block submission.
      .optional()
      .hidden({
        conditions: [{ field: 'accountType', operator: 'notEquals', value: 'business' }],
        operator: 'AND',
      }),
  )
  .addField('taxId', (f) =>
    f
      .type('text')
      .label('Tax ID')
      .optional()
      .hidden({
        conditions: [{ field: 'accountType', operator: 'notEquals', value: 'business' }],
        operator: 'AND',
      }),
  )
  .addField('newsletter', (f) => f.type('checkbox').label('Subscribe to newsletter').optional())
  .addField('frequency', (f) =>
    f
      .type('select')
      .label('Email Frequency')
      .options([
        { label: 'Daily', value: 'daily' },
        { label: 'Weekly', value: 'weekly' },
        { label: 'Monthly', value: 'monthly' },
      ])
      .disabled((values) => !values.newsletter)
      .optional(),
  )
  .addStep('main', ['accountType', 'name', 'company', 'taxId', 'newsletter', 'frequency'])
  .build();