Feature Guide

Repeating Sections for Dynamic Lists

February 2026 · 12 min read

"Add another line item." "Register 5 attendees." "List all your previous employers." Some forms need a variable number of entries. Users should add and remove items freely, with totals updating in real time.

FormTs handles this with dynamic subforms. Call addSubform() to create new sections at runtime, remove() to delete them. The form manages all field values automatically. For hands-on practice, try the interactive tutorial.

The Basic Pattern

The simplest case: users can add and remove items freely. No state needed - just call addSubform() and remove().

let nextId = 1;

// Container for items
const itemsContainer = form.addSubform('items');

const addItem = () => {
    const id = nextId++;

    const item = itemsContainer.addSubform(`item-${id}`, {
        title: `Item ${id}`
    });

    item.addRow(row => {
        row.addTextbox('name', {
            label: 'Item Name',
            isRequired: true
        });
        row.addButton('remove', {
            label: 'Remove',
            onClick: () => itemsContainer.remove(`item-${id}`)
        });
    });
};

// Add button (items will appear below it)
itemsContainer.addRow(row => {
    row.addButton('addItem', {
        label: '+ Add Item',
        onClick: addItem
    });
});

// Start with one item
addItem();

The key is the container subform (itemsContainer). The add button is added to the container first, then items appear below it as they're created. Each item is a subform inside the container.

Pro tip

Use an incrementing counter for IDs, never reuse them. If you delete item 2 and create a new item, it should be item 4 (or whatever's next), not item 2 again. This avoids ID collisions.

When You Need to Track IDs

The basic pattern works when you just need add/remove. But sometimes you need to iterate over all items - for example, to calculate a total. That's when you store IDs in form.state().

Invoice Line Items with Totals

// Track IDs to iterate over items in computedValue
const lineIds = form.state<number[]>([]);
let nextLineId = 1;

// Container for line items
const linesContainer = form.addSubform('lines', { title: 'Line Items' });

// Calculate total from all line items
const invoiceTotal = form.computedValue(() => {
    return lineIds().reduce((sum, id) => {
        const qty = linesContainer.integer(`line-${id}.qty`)?.value() ?? 0;
        const price = linesContainer.money(`line-${id}.price`)?.value() ?? 0;
        return sum + (qty * price);
    }, 0);
});

const addLineItem = () => {
    const id = nextLineId++;
    lineIds.update(ids => [...ids, id]);

    const line = linesContainer.addSubform(`line-${id}`);

    line.addRow(row => {
        row.addTextbox('desc', { label: 'Description' }, '2fr');

        row.addInteger('qty', {
            label: 'Qty',
            min: 1,
            defaultValue: 1
        }, '100px');

        row.addMoney('price', {
            label: 'Unit Price',
            currency: '$',
            defaultValue: 0
        }, '120px');

        row.addButton('remove', {
            label: '×',
            onClick: () => {
                linesContainer.remove(`line-${id}`);
                lineIds.update(ids => ids.filter(i => i !== id));
            }
        }, '50px');
    });
};

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

// Start with one line
addLineItem();

// Totals section (outside container)
const totalsSection = form.addSubform('totals', { title: 'Totals' });
totalsSection.addRow(row => {
    row.addPriceDisplay('total', {
        label: 'Total',
        computedValue: () => invoiceTotal(),
        variant: 'large'
    });
});

Key pattern here: create a container subform (linesContainer) first, then add items inside it. This way new items always appear in the right place, and the totals section stays at the bottom.

The computedValue iterates over IDs and reads values using dot notation (linesContainer.integer('line-1.qty')). When any field changes, the total recalculates automatically.

Syncing with Another Field

Event registration: the number of attendee sections should match the ticket count. Use onValueChange to add or remove sections when the number changes.

const attendeeIds = form.state<number[]>([]);
let nextAttendeeId = 1;

// Ticket selection at the top
form.addRow(row => {
    row.addInteger('tickets', {
        label: 'Number of Tickets',
        min: 1,
        max: 10,
        defaultValue: 1,
        onValueChange: (newCount) => {
            const count = newCount ?? 1;
            const currentCount = attendeeIds().length;

            for (let i = currentCount; i < count; i++) {
                addAttendee();
            }
            for (let i = currentCount; i > count; i--) {
                removeLastAttendee();
            }
        }
    });
});

// Container for attendees
const attendeesContainer = form.addSubform('attendees', { title: 'Attendee Details' });

const addAttendee = () => {
    const id = nextAttendeeId++;
    attendeeIds.update(ids => [...ids, id]);

    const section = attendeesContainer.addSubform(`attendee-${id}`, {
        title: () => `Attendee ${attendeeIds().indexOf(id) + 1}`
    });

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

    section.addRow(row => {
        row.addTextbox('dietary', {
            label: 'Dietary Restrictions',
            placeholder: 'e.g., vegetarian, gluten-free'
        });
    });
};

const removeLastAttendee = () => {
    const ids = attendeeIds();
    if (ids.length > 0) {
        const lastId = ids[ids.length - 1];
        attendeesContainer.remove(`attendee-${lastId}`);
        attendeeIds.update(list => list.slice(0, -1));
    }
};

// Start with one attendee
addAttendee();

When tickets go from 2 to 4, two new attendee sections appear. When tickets go from 4 to 2, the last two sections are removed. Users don't manually add/remove - it syncs automatically.

See dynamic forms in the calculator gallery.

Displaying Item Count

If you want to show "3 items" somewhere in the form, use a simple counter in state.

// Use counter when you need to display item count
const itemCount = form.state(0);
let nextId = 1;

// Container for items
const itemsContainer = form.addSubform('items');

const addItem = () => {
    const id = nextId++;
    itemCount.update(c => c + 1);

    const item = itemsContainer.addSubform(`item-${id}`);
    item.addRow(row => {
        row.addTextbox('value', { label: 'Value' });
        row.addButton('remove', {
            label: 'Remove',
            onClick: () => {
                itemsContainer.remove(`item-${id}`);
                itemCount.update(c => c - 1);
            }
        });
    });
};

// Add button with count display
itemsContainer.addRow(row => {
    row.addButton('add', {
        label: '+ Add Item',
        onClick: addItem
    });
    row.addTextPanel('count', {
        label: () => `${itemCount()} items`
    });
});

addItem();

The counter updates when items are added or removed. The text panel reads it reactively and displays the current count.

Limiting Maximum Items

To cap how many items users can add, check the count before adding and disable the button at the limit.

// Limit maximum number of items
const MAX_ITEMS = 5;
const itemCount = form.state(0);
let nextId = 1;

// Container for items
const itemsContainer = form.addSubform('items');

const addItem = () => {
    if (itemCount() >= MAX_ITEMS) return;

    const id = nextId++;
    itemCount.update(c => c + 1);

    const item = itemsContainer.addSubform(`item-${id}`);
    item.addRow(row => {
        row.addTextbox('value', { label: 'Value' });
        row.addButton('remove', {
            label: 'Remove',
            onClick: () => {
                itemsContainer.remove(`item-${id}`);
                itemCount.update(c => c - 1);
            }
        });
    });
};

// Add button with count display
itemsContainer.addRow(row => {
    row.addButton('add', {
        label: '+ Add Item',
        onClick: addItem,
        isDisabled: () => itemCount() >= MAX_ITEMS
    });
    row.addTextPanel('count', {
        label: () => `${itemCount()} of ${MAX_ITEMS}`
    });
});

addItem();

The add button disables when count reaches maximum. Users see "3 of 5" so they know the limit.

Calculating Totals

When each item has a numeric value, sum them with computedValue and reduce().

// Sum values across all items
const itemIds = form.state<number[]>([]);

const grandTotal = form.computedValue(() => {
    return itemIds().reduce((sum, id) => {
        const amount = form.money(`item-${id}.amount`)?.value() ?? 0;
        return sum + amount;
    }, 0);
});

form.addRow(row => {
    row.addPriceDisplay('grandTotal', {
        label: 'Grand Total',
        computedValue: () => grandTotal(),
        variant: 'highlight'
    });
});

The total recalculates when any amount changes, or when items are added/removed. The computed value tracks all its dependencies automatically.

Complete Example: Product Order

Putting it together: product selection with dropdown, quantity, and a calculated order total.

const orderIds = form.state<number[]>([]);
let nextOrderId = 1;

const products = [
    { id: 'widget', name: 'Widget', price: 25 },
    { id: 'gadget', name: 'Gadget', price: 50 },
    { id: 'gizmo', name: 'Gizmo', price: 75 }
];

// Container for order lines
const orderContainer = form.addSubform('order', { title: 'Your Order' });

const orderTotal = form.computedValue(() => {
    return orderIds().reduce((sum, id) => {
        const productId = orderContainer.dropdown(`line-${id}.product`)?.value();
        const qty = orderContainer.integer(`line-${id}.qty`)?.value() ?? 0;
        const product = products.find(p => p.id === productId);
        return sum + (product?.price ?? 0) * qty;
    }, 0);
});

const addOrderLine = () => {
    const id = nextOrderId++;
    orderIds.update(ids => [...ids, id]);

    const line = orderContainer.addSubform(`line-${id}`);
    line.addRow(row => {
        row.addDropdown('product', {
            label: 'Product',
            options: products.map(p => ({
                id: p.id,
                name: `${p.name} ($${p.price})`
            })),
            isRequired: true
        }, '1fr');

        row.addInteger('qty', {
            label: 'Quantity',
            min: 1,
            defaultValue: 1
        }, '100px');

        row.addButton('remove', {
            label: '×',
            onClick: () => {
                orderContainer.remove(`line-${id}`);
                orderIds.update(ids => ids.filter(i => i !== id));
            }
        }, '50px');
    });
};

// Add button inside container
orderContainer.addRow(row => {
    row.addButton('addProduct', {
        label: '+ Add Product',
        onClick: addOrderLine
    });
});

// Total outside container
form.addRow(row => {
    row.addPriceDisplay('total', {
        label: 'Order Total',
        computedValue: () => orderTotal(),
        variant: 'large'
    });
});

Common Use Cases

Work History

Job applications with employer, title, dates. Each entry is a subform with those fields. No special state needed unless you're calculating something across entries.

Shopping Cart

Products with quantity and options. Track IDs to calculate cart total. Dropdown for product, integer for quantity, computed total.

Multi-Stop Itinerary

Travel planning with multiple destinations. Each stop has location, dates, notes. Add/remove stops freely.

Team Members

Project forms with people and roles. Each entry has person dropdown and role dropdown. Sum hourly rates across team if needed.

Best Practices

Use Clear Subform IDs

attendee-1, line-item-3 - descriptive names make debugging easier and produce cleaner submitted data.

Never Reuse IDs

Keep incrementing the counter even after deletions. Reusing IDs can cause bugs with field values and reactivity.

Only Track What You Need

Basic add/remove? No state needed. Need to sum values? Track IDs in an array. Need to show count? Use a simple number counter. Don't over-engineer.

Common Questions

How do I start with multiple items?

Call your add function multiple times during setup. Loop 3 times to create 3 default items. They'll be ready when the form renders.

How do I access values from all items?

Store IDs in form.state, then iterate: itemIds().map(id => form.textbox(`item-${id}.name`)?.value()). This works in computedValue for reactive totals.

What happens to data when I remove a subform?

Gone. The subform and all its field values are removed. If you need undo, copy values to separate state before removing.

Can different items have different fields?

Yes. Pass a type parameter to your add function: addItem('product') vs addItem('service'). Create different field structures based on type.

Build Dynamic Forms

Add, remove, calculate. Repeating sections that stay in sync.