OfficeGest
Form Builder

Conditional Logic

Drive visible, disabled, required, and options from other field values with predicate functions.

A field's behaviour doesn't have to be fixed at config time. Four config keys — visible, disabled, required, and (for choice fields) options — accept a predicate function that receives the current form values and returns the resolved value. When any sibling field changes, predicate-bearing fields re-evaluate and re-render.

vat_number: {
    type: "text",
    label: "VAT number",
    visible: (values) => values.kind === "business",
}

This is the whole conditional-logic surface: there is no separate "rules" or "dependsOn" config. You read whatever sibling values you need inside the predicate and return a boolean (or, for options, an array of options).


The four predicate-capable keys

Each key accepts either a static value or a FieldValuesPredicate — a function of the form (values) => result. The predicate receives the full form values map (typed as Record<string, unknown>), so it can read any sibling field; narrow with a cast or guard at the call site.

KeyStatic typePredicate typeDefault when omittedEffect when resolved
visibleboolean(values) => booleantruefalse unmounts the field
disabledboolean(values) => booleanfalsetrue renders the input disabled
requiredboolean(values) => booleanfalsetrue adds the * indicator and aria-required
optionsSelectOption[](values) => SelectOption[]— (required on choice fields)recomputes the choice set

visible, disabled, and required live on every field via FieldConfigBase. options exists only on the choice field types — select, radio, combobox, and checkboxGroup — where its predicate form is typed as FieldValuesPredicate<SelectOption[]>.

The values argument is the same object for every predicate on the form — the complete values map, not just this field's value. Reading values.kind inside the vat_number field's predicate is exactly how cross-field logic works.


How resolution works

Each predicate prop collapses to its resolved value through one rule:

  • a static value is returned as-is;
  • a function is invoked with the current values;
  • undefined falls back to the per-key default (visibletrue, disabled and requiredfalse).

Only fields that carry at least one function-form predicate subscribe to the form values store. A field whose config is entirely static keeps its isolated, per-field re-render behaviour and is not re-rendered when an unrelated field changes. This keeps the common case cheap: adding a predicate to one field does not make every other field reactive.

Because predicate fields subscribe to the whole values object, a conditional field re-renders once per change to any field while the predicate is present. This is by design — the subscription is the full values map, not a declared dependency slice. It matters only on very large forms with many conditional fields.


Visibility: show and hide fields

A visible predicate that returns false unmounts the field — it is removed from the DOM, not hidden with display: none. The field's value is retained in form state, so toggling the predicate back to true restores whatever the user had typed.

This example (from the conditionals playground) reveals a VAT field only for business customers:

const visibilitySchema = z.object({
    kind: z.enum(["personal", "business"]),
    name: z.string().min(1, "Name is required"),
    vat_number: z.string(),
});

const visibilityFields: FormFields<typeof visibilitySchema> = {
    kind: {
        type: "radio",
        label: "Customer kind",
        options: [
            {label: "Personal", value: "personal"},
            {label: "Business", value: "business"},
        ],
    },
    name: {type: "text", label: "Name", required: true},
    vat_number: {
        type: "text",
        label: "VAT number",
        placeholder: "PT123456789",
        visible: (values) => values.kind === "business",
    },
};

Hidden fields are still validated

A field hidden by a false visible predicate keeps its value in form state and is still validated on submit. Predicate visibility does not skip validation. A z.string().min(1) field that is currently unmounted but holds an empty stored value will fail submit-time validation — and because the field is unmounted, there is no visible affordance to show the error, so the Submit button appears inert.

The schema branch for the hidden case must permit the stored value. Make it .optional() / .nullable() where the branch genuinely has no value, or model the two states with z.discriminatedUnion for branch-conditional requirements.

In the example above, vat_number is declared z.string() (not z.string().min(1)), so a hidden, empty VAT number validates cleanly. If business customers must supply a VAT number, encode that with a discriminated union rather than a bare .min(1):

const schema = z.discriminatedUnion("kind", [
    z.object({
        kind: z.literal("personal"),
        vat_number: z.string().optional(),
    }),
    z.object({
        kind: z.literal("business"),
        vat_number: z.string().min(1, "VAT number is required"),
    }),
]);

Disabled and required

disabled and required never unmount the field — they flip flags reactively. A common pairing is an opt-in checkbox that drives a dependent field:

const disabledRequiredFields: FormFields<typeof schema> = {
    newsletter: {
        type: "checkbox",
        label: "Subscribe to the newsletter",
    },
    newsletter_email: {
        type: "text",
        label: "Newsletter email",
        placeholder: "name@example.com",
        disabled: (values) => values.newsletter !== true,
        required: (values) => values.newsletter === true,
    },
};

required is a UI hint only

The required flag is presentational. When it resolves to true the field shell renders an asterisk next to the label and sets aria-required on the input:

  • the asterisk is aria-hidden="true" and styled text-destructive — it is a visual cue only and is not announced to screen readers;
  • aria-required="true" is set on the input itself, which is what assistive technology reads.

required does not enforce anything. Hard validation must live in the Zod schema. A reactive required: (values) => … predicate changes only the asterisk and aria-required; to actually reject an empty value, encode the requirement in the schema (with .refine or a discriminatedUnion for the conditional case).


Dependent options

On choice fields, options accepts a predicate that recomputes the available choices from current values. Here the priority scale changes with the selected region:

const dependentOptionsFields: FormFields<typeof schema> = {
    region: {
        type: "select",
        label: "Region",
        options: [
            {label: "EU", value: "EU"},
            {label: "Worldwide", value: "WORLDWIDE"},
        ],
    },
    priority: {
        type: "radio",
        label: "Priority",
        options: (values) =>
            values.region === "EU"
                ? [
                      {label: "Standard", value: "standard"},
                      {label: "Express", value: "express"},
                  ]
                : [
                      {label: "Economy", value: "economy"},
                      {label: "Standard", value: "standard"},
                      {label: "Express", value: "express"},
                  ],
    },
};

Recomputing options does not auto-clear a now-invalid selection. If the user picked economy for a worldwide form and then switched the region to EU (where economy is no longer offered), the stored value stays economy. Reconciling a stale selection against the new option set is the consumer's responsibility — reset it in an effect or validate it in the schema.


Predicates span the whole form

Predicates resolve against the single shared form store, so a dependency does not have to be visually adjacent to the field that reacts to it. A field in one tab can react to a field in another tab — both read and write the same store through <form.Provider>.

const crossTabFields: FormFields<typeof schema> = {
    company_name: {type: "text", label: "Company name"},
    tax_residency: {
        type: "text",
        label: "Tax residency",
        visible: (values) =>
            typeof values.company_name === "string" &&
            values.company_name.length > 0,
    },
};

When company_name changes on the Company tab, the tax_residency field on the Tax tab re-evaluates its visible predicate — no manual wiring between the tabs is needed.


Predicates inside array items

The array field type takes an item config describing one row. Item configs are typed as StaticFormFields, which excludes the function form of visible, disabled, required, and options. A predicate inside item is a compile-time error, not a silent runtime no-op — the array primitive does not subscribe to values per row.

Field-level error and validator paths inside arrays use bracket notation against the row index: a child field on the first row resolves under parent[0].child. Keep that in mind when writing schema messages or validate codes for array items.

Use static booleans and static option arrays inside item configs; if a row field genuinely needs to react to sibling values, model it at the top level instead of nesting it in an array.