# Conditional Logic

URL: https://docs.forms.saastro.io/docs/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

```tsx
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.

```tsx
// 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.

```tsx
// 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.

```tsx
// 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:

```tsx
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

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

### OR — At least one condition must be true

```tsx
.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.

```tsx
.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

| 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](/docs/use-computed-fields).

---

## Complete Example

```tsx
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](/docs/multi-step) — Conditional step routing uses the same `ConditionGroup` system
- [useComputedFields](/docs/use-computed-fields) — Derive a field's value from other fields
- [Styling](/docs/styling) — `classNames()` method for conditional CSS
- [Layout System](/docs/layout) — Responsive column spans per breakpoint
