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, andradioremove their change handler wheneverreadOnlyis set at all — passing a function orConditionGroupto these types makes them permanently read-only, regardless of how the condition evaluates. Usedisabledfor 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:
| Operator | Description | Value Required |
|---|---|---|
equals | Strict equality (===) | Yes |
notEquals | Strict inequality (!==) | Yes |
contains | String/array includes value | Yes |
notContains | String/array does not include value | Yes |
greaterThan | Numeric > | Yes |
lessThan | Numeric < | Yes |
greaterThanOrEqual | Numeric >= | Yes |
lessThanOrEqual | Numeric <= | Yes |
isTrue | Value is exactly true (strict === true) | No |
isFalse | Value is exactly false (strict === false) | No |
isEmpty | Value is falsy ('', null, undefined, 0, false), a whitespace-only string, or an empty array | No |
isNotEmpty | Value is not null/undefined, not a blank string, and not an empty array | No |
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. Whendefaultis'hidden', the renderer removes the field entirely (it rendersnullat 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 fromdefault: 'visible'(or omitdefault) and hide at specific breakpoints.
Function vs Declarative
| Approach | Serializable | Use Case |
|---|---|---|
boolean | Yes | Static, never changes |
ConditionGroup | Yes | Pure JSON — storable in a database/API, usable by visual form-builder tooling |
(values) => boolean | No | Complex 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();
Related
- Multi-Step Forms — Conditional step routing uses the same
ConditionGroupsystem - useComputedFields — Derive a field’s value from other fields
- Styling —
classNames()method for conditional CSS - Layout System — Responsive column spans per breakpoint