Feature Guide

Conditional Logic Deep Dive

January 2026 · 18 min read

Static forms ask the same questions regardless of answers. Smart forms adapt. They show relevant fields, hide irrelevant ones, and calculate values automatically. This is the power of conditional logic.

The Three Pillars

FormTs conditional logic rests on three reactive properties:

  • isVisible - Show or hide fields based on conditions
  • isRequired - Make validation conditional
  • computedValue - Calculate values automatically

Each accepts a function that re-runs whenever dependencies change. No manual subscriptions. No refresh buttons. Just reactive updates.

Conditional Visibility

Basic Show/Hide

The simplest pattern: show a field when a checkbox is checked. See the Conditional Visibility tutorial for more examples.

form.addRow(row => {
    row.addCheckbox('hasDiscount', {
        label: 'I have a discount code'
    });
});

form.addRow(row => {
    row.addTextbox('discountCode', {
        label: 'Discount Code',
        isVisible: () => form.checkbox('hasDiscount')?.value() === true
    });
});

The discount code field only appears when the checkbox is checked. When unchecked, it disappears - and its value is excluded from submission.

Multiple Exclusive Fields

Show different fields based on a dropdown selection:

form.addRow(row => {
    row.addDropdown('contactMethod', {
        label: 'Preferred contact method',
        options: [
            { id: 'email', name: 'Email' },
            { id: 'phone', name: 'Phone' },
            { id: 'mail', name: 'Postal Mail' }
        ]
    });
});

form.addRow(row => {
    row.addEmail('email', {
        label: 'Email Address',
        isVisible: () => form.dropdown('contactMethod')?.value() === 'email'
    });
});

form.addRow(row => {
    row.addTextbox('phone', {
        label: 'Phone Number',
        isVisible: () => form.dropdown('contactMethod')?.value() === 'phone'
    });
});

form.addRow(row => {
    row.addTextarea('address', {
        label: 'Mailing Address',
        isVisible: () => form.dropdown('contactMethod')?.value() === 'mail',
        rows: 3
    });
});

Only one contact field shows at a time. Clean, focused, no confusion.

Pro tip

Hidden fields don't submit their values. If you need to preserve values when hiding, use isReadOnly instead or store in form.state().

Conditional Subforms

Hide entire sections, not just individual fields. See the Subforms tutorial for section organization.

const businessDetails = form.addSubform('businessDetails', {
    title: 'Business Information',
    isVisible: () => form.radioButton('customerType')?.value() === 'business'
});

businessDetails.addRow(row => {
    row.addTextbox('companyName', { label: 'Company Name', isRequired: true });
});

businessDetails.addRow(row => {
    row.addTextbox('taxId', { label: 'Tax ID / VAT Number' });
});

The entire "Business Information" section appears only for business customers. All fields inside inherit the visibility.

Conditional Validation

Sometimes a field should only be required under certain conditions. See the Conditional Required tutorial for patterns.

form.addRow(row => {
    row.addRadioButton('needsInvoice', {
        label: 'Do you need an invoice?',
        options: [
            { id: 'yes', name: 'Yes' },
            { id: 'no', name: 'No' }
        ]
    });
});

form.addRow(row => {
    row.addTextbox('companyName', {
        label: 'Company Name',
        isRequired: () => form.radioButton('needsInvoice')?.value() === 'yes',
        isVisible: () => form.radioButton('needsInvoice')?.value() === 'yes'
    });
});

form.addRow(row => {
    row.addTextbox('taxId', {
        label: 'Tax ID',
        isRequired: () => form.radioButton('needsInvoice')?.value() === 'yes',
        isVisible: () => form.radioButton('needsInvoice')?.value() === 'yes'
    });
});

Company name and tax ID are only required (and visible) when the user needs an invoice. Otherwise, the form submits without them.

See conditional logic in action

Computed Values

Basic Calculations

Automatically calculate totals, taxes, or any derived value. See the Computed Values tutorial for arithmetic patterns.

form.addRow(row => {
    row.addInteger('quantity', {
        label: 'Quantity',
        defaultValue: 1,
        min: 1
    });
});

form.addRow(row => {
    row.addMoney('unitPrice', {
        label: 'Unit Price',
        defaultValue: 25,
        currency: '$'
    });
});

form.addRow(row => {
    row.addPriceDisplay('total', {
        label: 'Total',
        computedValue: () => {
            const qty = form.integer('quantity')?.value() ?? 0;
            const price = form.money('unitPrice')?.value() ?? 0;
            return qty * price;
        }
    });
});

The total updates instantly as quantity or price changes. No submit button, no recalculate - just reactive updates.

Conditional Calculations

Combine conditions with calculations for complex pricing:

form.addRow(row => {
    row.addPriceDisplay('total', {
        label: 'Total',
        computedValue: () => {
            const subtotal = form.money('subtotal')?.value() ?? 0;
            const hasDiscount = form.checkbox('hasDiscount')?.value();
            const discountPercent = form.integer('discountPercent')?.value() ?? 0;

            if (hasDiscount && discountPercent > 0) {
                return subtotal * (1 - discountPercent / 100);
            }
            return subtotal;
        },
        // Show original price when discounted
        originalPrice: () => {
            const hasDiscount = form.checkbox('hasDiscount')?.value();
            const discountPercent = form.integer('discountPercent')?.value() ?? 0;
            if (hasDiscount && discountPercent > 0) {
                return form.money('subtotal')?.value() ?? 0;
            }
            return undefined;
        }
    });
});

When a discount is applied, the total shows the discounted price with the original price struck through.

Complex Conditions

AND Conditions

Require multiple conditions to be true:

// Show field only when BOTH conditions are true
form.addRow(row => {
    row.addTextbox('emergencyContact', {
        label: 'Emergency Contact',
        isVisible: () => {
            const isMinor = (form.integer('age')?.value() ?? 0) < 18;
            const needsSupervision = form.checkbox('needsSupervision')?.value();
            return isMinor && needsSupervision;
        }
    });
});

Emergency contact shows only when the person is both under 18 AND needs supervision. Both must be true.

OR Conditions

Show when any condition is true:

// Show field when ANY condition is true
form.addRow(row => {
    row.addTextarea('additionalInfo', {
        label: 'Please provide more details',
        isVisible: () => {
            const rating = form.starRating('satisfaction')?.value() ?? 0;
            const hasIssue = form.checkbox('hadIssue')?.value();
            return rating <= 2 || hasIssue;  // Low rating OR reported issue
        }
    });
});

Additional info field appears for low ratings OR when an issue is reported. Either triggers the field.

Nested Conditions

Real-world logic often combines multiple factors:

// Multiple nested conditions
form.addRow(row => {
    row.addPriceDisplay('shippingCost', {
        label: 'Shipping',
        computedValue: () => {
            const country = form.dropdown('country')?.value();
            const subtotal = form.money('subtotal')?.value() ?? 0;
            const shippingMethod = form.radioButton('shipping')?.value();

            // Free shipping for orders over $100 in US
            if (country === 'US' && subtotal >= 100) {
                return 0;
            }

            // Express shipping costs more
            if (shippingMethod === 'express') {
                return country === 'US' ? 15 : 35;
            }

            // Standard shipping
            return country === 'US' ? 5 : 20;
        }
    });
});

Shipping cost depends on country, order total, and shipping method. The function handles all combinations cleanly.

Shared State

For complex forms, use form.state() to share values across multiple conditions:

// Create shared state
const selectedTier = form.state<'basic' | 'pro' | 'enterprise'>('basic');

form.addRow(row => {
    row.addRadioButton('tier', {
        label: 'Select Plan',
        options: [
            { id: 'basic', name: 'Basic - $9/mo' },
            { id: 'pro', name: 'Pro - $29/mo' },
            { id: 'enterprise', name: 'Enterprise - $99/mo' }
        ],
        defaultValue: 'basic',
        onValueChange: (value) => {
            if (value) selectedTier.set(value as any);
        }
    });
});

// Use state in multiple places
form.addRow(row => {
    row.addTextPanel('features', {
        computedValue: () => {
            const tier = selectedTier();
            if (tier === 'enterprise') return '✓ Unlimited users ✓ API access ✓ Priority support';
            if (tier === 'pro') return '✓ 10 users ✓ API access';
            return '✓ 2 users';
        }
    });
});

State is reactive - when it changes, all dependent fields update. Useful when multiple parts of the form depend on the same value.

Dynamic Options

Dropdown and radio button options can also be conditional. See the Dynamic Labels tutorial for more patterns.

form.addRow(row => {
    row.addDropdown('country', {
        label: 'Country',
        options: [
            { id: 'US', name: 'United States' },
            { id: 'CA', name: 'Canada' },
            { id: 'UK', name: 'United Kingdom' }
        ]
    });
});

form.addRow(row => {
    row.addDropdown('state', {
        label: 'State / Province',
        options: () => {
            const country = form.dropdown('country')?.value();
            if (country === 'US') {
                return [
                    { id: 'CA', name: 'California' },
                    { id: 'NY', name: 'New York' },
                    { id: 'TX', name: 'Texas' }
                ];
            }
            if (country === 'CA') {
                return [
                    { id: 'ON', name: 'Ontario' },
                    { id: 'BC', name: 'British Columbia' },
                    { id: 'QC', name: 'Quebec' }
                ];
            }
            return [];
        },
        isVisible: () => {
            const country = form.dropdown('country')?.value();
            return country === 'US' || country === 'CA';
        }
    });
});

State/province options change based on country selection. The field itself hides for countries without subdivisions.

Dynamic Labels

Labels, placeholders, and tooltips can all be reactive:

form.addRow(row => {
    row.addMoney('amount', {
        label: () => {
            const type = form.radioButton('transactionType')?.value();
            if (type === 'deposit') return 'Deposit Amount';
            if (type === 'withdrawal') return 'Withdrawal Amount';
            return 'Amount';
        },
        placeholder: () => {
            const type = form.radioButton('transactionType')?.value();
            return type === 'deposit' ? 'Min $10' : 'Max $1000';
        }
    });
});

The same field adapts its label and placeholder based on context. One field, multiple personalities.

Dynamic Add/Remove Fields

Sometimes you need to add or remove entire sections at runtime. Use form.state() to track items and buttons to trigger changes. See the Dynamic Add/Remove Fields tutorial for complete patterns.

Event Registration with Multiple Attendees

Let users add multiple attendees to an event registration:

// Track attendees with state
const attendeeCount = form.state(1);

// Container for all attendees
const attendeesSection = form.addSubform('attendees', {
    title: 'Event Attendees'
});

// Function to create attendee fields
function addAttendeeFields(index: number) {
    const attendee = attendeesSection.addSubform(`attendee-${index}`, {
        title: `Attendee ${index}`,
        isCollapsible: true
    });

    attendee.addRow(row => {
        row.addTextbox('name', {
            label: 'Full Name',
            isRequired: true
        });
    });

    attendee.addRow(row => {
        row.addEmail('email', {
            label: 'Email',
            isRequired: true
        });
    });

    attendee.addRow(row => {
        row.addDropdown('diet', {
            label: 'Dietary Requirements',
            options: [
                { id: 'none', name: 'None' },
                { id: 'vegetarian', name: 'Vegetarian' },
                { id: 'vegan', name: 'Vegan' },
                { id: 'gluten-free', name: 'Gluten Free' }
            ]
        });
    });

    // Remove button (not for first attendee)
    if (index > 1) {
        attendee.addRow(row => {
            row.addButton('remove', {
                label: 'Remove Attendee',
                onClick: () => {
                    attendeesSection.remove(`attendee-${index}`);
                    attendeeCount.update(n => n - 1);
                }
            });
        });
    }
}

// Create first attendee
addAttendeeFields(1);

// Add Attendee button
form.addRow(row => {
    row.addButton('addAttendee', {
        label: '+ Add Another Attendee',
        onClick: () => {
            const newIndex = attendeeCount() + 1;
            addAttendeeFields(newIndex);
            attendeeCount.set(newIndex);
        }
    });
});

Key patterns:

  • form.state() tracks how many items exist
  • A function creates the subform with all fields
  • Each item gets a unique ID based on index
  • Remove button calls subform.remove(id)

Pro tip

Always give dynamic subforms unique IDs like attendee-1, attendee-2. Don't reuse IDs even after removal - increment a counter instead.

Auto-Expand on Selection

Use onValueChange to create sections when needed:

// Auto-expand form based on selection
form.addRow(row => {
    row.addRadioButton('hasTeam', {
        label: 'Are you registering a team?',
        options: [
            { id: 'no', name: 'Just myself' },
            { id: 'yes', name: 'Yes, I have a team' }
        ],
        defaultValue: 'no',
        onValueChange: (value) => {
            if (value === 'yes' && !form.field('teamMembers.member-1.name')) {
                // First time selecting "yes" - add team section
                createTeamSection();
            }
        }
    });
});

function createTeamSection() {
    const team = form.addSubform('teamMembers', {
        title: 'Team Members',
        isVisible: () => form.radioButton('hasTeam')?.value() === 'yes'
    });

    // Add first team member
    addTeamMember(team, 1);

    // Add more button inside section
    team.addRow(row => {
        row.addButton('addMember', {
            label: '+ Add Team Member',
            onClick: () => {
                memberCount.update(n => n + 1);
                addTeamMember(team, memberCount());
            }
        });
    });
}

The team section is created only when the user selects "Yes, I have a team". Check if it already exists to avoid duplicates.

Work Experience Builder

Dynamic sections are perfect for job applications:

// Dynamic work history - common in job applications
const experienceCount = form.state(0);

const workHistory = form.addSubform('workHistory', {
    title: 'Work Experience'
});

function addExperience(index: number) {
    const exp = workHistory.addSubform(`exp-${index}`, {
        title: index === 1 ? 'Most Recent Position' : `Position ${index}`
    });

    exp.addRow(row => {
        row.addTextbox('company', { label: 'Company', isRequired: true }, '1fr');
        row.addTextbox('title', { label: 'Job Title', isRequired: true }, '1fr');
    });

    exp.addRow(row => {
        row.addTextbox('startDate', { label: 'Start Date', placeholder: 'MM/YYYY' }, '1fr');
        row.addTextbox('endDate', {
            label: 'End Date',
            placeholder: 'MM/YYYY or Present',
            isVisible: () => !form.checkbox(`workHistory.exp-${index}.current`)?.value()
        }, '1fr');
    });

    exp.addRow(row => {
        row.addCheckbox('current', { label: 'I currently work here' });
    });

    exp.addRow(row => {
        row.addTextarea('description', {
            label: 'Key Responsibilities',
            rows: 3,
            placeholder: 'Describe your main responsibilities and achievements...'
        });
    });

    // Remove button
    exp.addRow(row => {
        row.addButton('remove', {
            label: 'Remove This Position',
            onClick: () => {
                workHistory.remove(`exp-${index}`);
            }
        });
    });
}

// "Add Experience" button
workHistory.addRow(row => {
    row.addButton('addExp', {
        label: '+ Add Work Experience',
        onClick: () => {
            experienceCount.update(n => n + 1);
            addExperience(experienceCount());
        }
    });
});

Each position is a complete subform with its own fields. The "I currently work here" checkbox hides the end date field using standard isVisible.

See dynamic fields in a complete example

Order Line Items

For e-commerce, track items in state to calculate totals:

// Order form with dynamic line items
const items = form.state<{id: number; qty: number; price: number}[]>([]);
let itemCounter = 0;

const orderSection = form.addSubform('order', { title: 'Order Items' });

function addLineItem() {
    itemCounter++;
    const itemId = itemCounter;

    const item = orderSection.addSubform(`item-${itemId}`, {});

    item.addRow(row => {
        row.addDropdown('product', {
            label: 'Product',
            options: [
                { id: 'widget-a', name: 'Widget A - $25' },
                { id: 'widget-b', name: 'Widget B - $45' },
                { id: 'widget-c', name: 'Widget C - $75' }
            ],
            onValueChange: () => updateItemTotal(itemId)
        }, '2fr');

        row.addInteger('qty', {
            label: 'Qty',
            defaultValue: 1,
            min: 1,
            onValueChange: () => updateItemTotal(itemId)
        }, '80px');

        row.addButton('remove', {
            label: '×',
            onClick: () => {
                orderSection.remove(`item-${itemId}`);
                items.update(arr => arr.filter(i => i.id !== itemId));
            }
        }, '50px');
    });

    items.update(arr => [...arr, { id: itemId, qty: 1, price: 0 }]);
}

// Add Item button
orderSection.addRow(row => {
    row.addButton('add', {
        label: '+ Add Item',
        onClick: addLineItem
    });
});

// Order total
form.addRow(row => {
    row.addPriceDisplay('total', {
        label: 'Order Total',
        computedValue: () => {
            return items().reduce((sum, item) => sum + (item.qty * item.price), 0);
        },
        variant: 'large'
    });
});

The state array holds item data for total calculation. When items are added or removed, the total updates automatically.

Complete Example

A form that adapts entirely based on customer type:

export function conditionalForm(form: FormTs) {
    // Customer type affects entire form structure
    form.addRow(row => {
        row.addRadioButton('customerType', {
            label: 'I am a...',
            options: [
                { id: 'individual', name: 'Individual' },
                { id: 'business', name: 'Business' }
            ],
            defaultValue: 'individual'
        });
    });

    // Personal info (always visible)
    form.addRow(row => {
        row.addTextbox('name', {
            label: () => form.radioButton('customerType')?.value() === 'business'
                ? 'Contact Name'
                : 'Full Name',
            isRequired: true
        });
    });

    // Business-only section
    const business = form.addSubform('business', {
        title: 'Business Details',
        isVisible: () => form.radioButton('customerType')?.value() === 'business'
    });

    business.addRow(row => {
        row.addTextbox('company', { label: 'Company Name', isRequired: true });
    });

    business.addRow(row => {
        row.addDropdown('size', {
            label: 'Company Size',
            options: [
                { id: 'small', name: '1-10 employees' },
                { id: 'medium', name: '11-50 employees' },
                { id: 'large', name: '51+ employees' }
            ]
        });
    });

    // Volume discount for large businesses
    business.addRow(row => {
        row.addTextPanel('discount', {
            computedValue: () => {
                const size = form.dropdown('size')?.value();
                if (size === 'large') return '🎉 You qualify for volume pricing!';
                return '';
            },
            isVisible: () => form.dropdown('size')?.value() === 'large'
        });
    });

    form.configureSubmitButton({
        label: () => form.radioButton('customerType')?.value() === 'business'
            ? 'Request Business Quote'
            : 'Continue'
    });
}

This form:

  • Changes label text based on customer type
  • Shows/hides entire business section
  • Displays volume discount message conditionally
  • Adapts the submit button text

Performance Considerations

Reactive functions run frequently. Keep them fast:

  • Keep functions simple - Basic comparisons and math are fine
  • Avoid API calls - Never fetch data inside isVisible/computedValue
  • Use state for expensive calculations - Calculate once, reference many times
  • Don't worry about micro-optimizations - FormTs handles caching

Debugging Tips

Function Not Running?

Check that you're passing a function, not calling it:

// Wrong - runs once at creation
isVisible: form.checkbox('toggle')?.value() === true

// Right - runs whenever toggle changes
isVisible: () => form.checkbox('toggle')?.value() === true

Value Always Undefined?

Check the field ID matches exactly. Use optional chaining:

// Safe - won't crash if field doesn't exist
const value = form.textbox('name')?.value() ?? 'default';

Circular Dependencies?

If field A depends on field B and B depends on A, you'll get infinite updates. Restructure to break the cycle.

Common Questions

Do hidden fields submit their values?

No. When isVisible returns false, the field's value is excluded from the submission data. This prevents sending irrelevant data. If you need to preserve values, use isReadOnly instead or store in form.state().

Can I use async functions in isVisible?

No. isVisible, isRequired, and computedValue must be synchronous. For async operations, load data first and store in form.state(), then reference that state in your conditions.

How often do reactive functions run?

Functions run whenever any field they reference changes value. FormTs tracks dependencies automatically. If your function reads fieldA and fieldB, it reruns when either changes.

Can conditions reference fields in subforms?

Yes. Use the full path: form.textbox('subformId.fieldId')?.value(). Fields can reference any other field in the form, regardless of nesting level.

What's the difference between computedValue and onValueChange?

computedValue sets the field's value automatically - the user can't edit it. onValueChange is a callback that runs when the user changes the value - useful for side effects like updating state or triggering calculations elsewhere.

Ready to Build Smart Forms?

Create forms that adapt to your users. Start with conditional logic today.