Field Array
View as MarkdownA repeatable group of fields, for letting users add and remove rows like "add another person".
<f:variable name="people" value="{0: {firstName: 'Ada', lastName: 'Lovelace'}, 1: {firstName: 'Alan', lastName: 'Turing'}}" />
<ui:fieldArray.root name="people" itemCount="{people -> f:count()}" class="w-full max-w-md">
<primitives:fieldArray.itemTemplate>
<ui:fieldArray.item>
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="firstName" required="{true}">
<ui:input.root>
<ui:input.label>First name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="lastName" required="{true}">
<ui:input.root>
<ui:input.label>Last name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</primitives:fieldArray.itemTemplate>
<ui:fieldArray.itemGroup>
<f:for each="{people}" as="person" iteration="it">
<ui:fieldArray.item index="{it.index}">
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="firstName" defaultValue="{person.firstName}" required="{true}">
<ui:input.root>
<ui:input.label>First name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="lastName" defaultValue="{person.lastName}" required="{true}">
<ui:input.root>
<ui:input.label>Last name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</f:for>
<ui:fieldArray.emptyState>No people added yet.</ui:fieldArray.emptyState>
</ui:fieldArray.itemGroup>
<ui:fieldArray.addTrigger asChild="{true}">
<ui:button variant="secondary">Add person</ui:button>
</ui:fieldArray.addTrigger>
</ui:fieldArray.root>
import { mountAll } from 'fluid-primitives';
import { Field } from 'fluid-primitives/field';
import { FieldArray, type FieldArrayAnnounceInfo } from 'fluid-primitives/field-array';
import { Input } from 'fluid-primitives/input';
// A row appended client-side (via api.append()) contains its own nested Field/Input instances
// that aren't hydrated yet - `mountAll` is safe to call more than once (already-mounted instances
// are skipped), so re-running these two on every `itemadded` picks up whatever a row's own
// itemTemplate contains without FieldArray needing to know about Field/Input itself.
function mountRowComponents() {
mountAll('ui:field', ({ props }) => {
const field = new Field(props);
field.init();
return field;
});
mountAll('ui:input', ({ props }) => {
const input = new Input(props);
input.init();
return input;
});
}
mountAll('ui:fieldArray', ({ props }) => {
const serverTranslations = props.translations as
{ rowAdded?: string; rowRemoved?: string } | undefined;
const maxItems = props.maxItems as number | undefined;
const fieldArray = new FieldArray({
...props,
onItemAdded: () => {
mountRowComponents();
updateStatusText();
},
onItemRemoved: updateStatusText,
translations: {
...serverTranslations,
// A custom announcement built from the row's own fields, falling back to the
// translated default (with its `%number%` placeholder) while they're still empty -
// e.g. right after a row was just added and hasn't been filled in yet.
rowRemoved: ({ getFieldValue }: FieldArrayAnnounceInfo) => {
const firstName = getFieldValue('firstName');
const lastName = getFieldValue('lastName');
if (!firstName && !lastName) return serverTranslations?.rowRemoved ?? false;
return `Person ${[firstName, lastName].filter(Boolean).join(' ')} removed.`;
},
},
});
// Only the "Limiting Row Count" example authors a `status` ref inside its own row markup -
// `getElement` returns null for every other example, so this is a no-op there. Kept here
// rather than duplicated per-example since every example already shares this one entry file.
function updateStatusText() {
const statusEl = fieldArray.getElement<HTMLElement>('status');
if (!statusEl || maxItems === undefined) return;
const count = fieldArray.api.getRows().length;
statusEl.textContent = `${count} of ${maxItems} added (${maxItems - count} remaining)`;
}
fieldArray.init();
return fieldArray;
});
Features
Permalink to heading "Features"- Add and remove rows client-side, with contiguous
name[0],name[1], ... indexing maintained automatically as rows are removed - Existing rows render for real on the server - no JavaScript required to see or submit data that's already there
- Works with
Fieldand any Field-aware primitive (Input, Select, Checkbox, ...) nested inside a row - Row
names use the same bracket notation (people[0][firstName]) Extbase andFormValuesalready understand - Optional
minItems/maxItemsbounds -addTrigger/removeTriggerdisable themselves once a bound is reached, andappend()/remove()are no-ops past it emptyState/addTrigger/removeTriggeralready reflect the correct hidden/disabled state in the server-rendered HTML, fromitemCount, not only once JavaScript hydrates
Installation
Permalink to heading "Installation"typo3 ui:add field-array
Please copy the files manually from GitHub into your project.
Read more about installing Components and Primitives.
Also install Field
FieldArray only manages rows and indexing - each row's own fields still need the Field primitive (typo3 ui:add field) nested inside.
How It Works
Permalink to heading "How It Works"A row's markup is authored once, inside itemTemplate - a <template> element that's never rendered directly, only cloned client-side whenever addTrigger is clicked. Existing rows are a separate, ordinary loop over data you already have, rendered for real:
<primitives:fieldArray.itemTemplate>
<primitives:fieldArray.item>
<!-- one row's fields, authored once -->
</primitives:fieldArray.item>
</primitives:fieldArray.itemTemplate>
<primitives:fieldArray.itemGroup>
<f:for each="{people}" as="person" iteration="it">
<primitives:fieldArray.item index="{it.index}">
<!-- the same row markup, authored again with this person's data -->
</primitives:fieldArray.item>
</f:for>
</primitives:fieldArray.itemGroup>
Yes, the row is written twice - once for the stencil, once for the loop. This isn't a shortcut taken for FieldArray specifically; it's the same pattern FileUpload already uses for mixing already-uploaded files with newly-picked ones (see its docs' "Editing" section), and it's what keeps every row genuinely server-rendered rather than reconstructed from JSON after the page loads.
Each fieldArray.item's index prop (omitted inside itemTemplate, where no real index exists yet) automatically prefixes every nested Field's name - <ui:field name="firstName"> inside row 1 of an array named people becomes people[1][firstName] without you writing that out yourself.
Reflecting Row Count on the Server
Permalink to heading "Reflecting Row Count on the Server"root takes a required itemCount prop - the number of rows you're about to render in the loop above. Nothing counts your rows for you (they're your own f:for loop, not a collection FieldArray owns), so pass it explicitly, e.g. itemCount="{people -> f:count()}". It's what lets emptyState/addTrigger/removeTrigger start in the correct hidden/disabled state in the server-rendered HTML itself, matching minItems/maxItems, rather than only correcting themselves once JavaScript hydrates.
<primitives:fieldArray.root name="people" itemCount="{people -> f:count()}">
<!-- ... -->
</primitives:fieldArray.root>
Examples
Permalink to heading "Examples"Starting Empty
Permalink to heading "Starting Empty"A FieldArray with no rows yet - emptyState shows until the first row is added.
<ui:fieldArray.root name="people" itemCount="0" class="w-full max-w-md">
<primitives:fieldArray.itemTemplate>
<ui:fieldArray.item>
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="firstName" required="{true}">
<ui:input.root>
<ui:input.label>First name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="lastName" required="{true}">
<ui:input.root>
<ui:input.label>Last name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</primitives:fieldArray.itemTemplate>
<ui:fieldArray.itemGroup>
<ui:fieldArray.emptyState>No people added yet.</ui:fieldArray.emptyState>
</ui:fieldArray.itemGroup>
<ui:fieldArray.addTrigger asChild="{true}">
<ui:button variant="secondary">Add person</ui:button>
</ui:fieldArray.addTrigger>
</ui:fieldArray.root>
Limiting Row Count
Permalink to heading "Limiting Row Count"minItems="1" disables removeTrigger once a single row remains; maxItems="3" disables addTrigger once three rows exist. The status text below the rows ("2 of 3 added") isn't a FieldArray feature by itself - it's a plain element the row markup authors itself ({ui:ref(name: 'status', context: 'fieldArray')}), kept in sync from onItemAdded/onItemRemoved the same way this example already mounts each row's own Field/Input.
<f:variable name="people" value="{0: {firstName: 'Ada', lastName: 'Lovelace'}}" />
<f:variable name="peopleCount" value="{people -> f:count()}" />
<f:variable name="maxPeople" value="3" />
<ui:fieldArray.root name="people" itemCount="{peopleCount}" minItems="1" maxItems="{maxPeople}" class="w-full max-w-md">
<primitives:fieldArray.itemTemplate>
<ui:fieldArray.item>
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="firstName" required="{true}">
<ui:input.root>
<ui:input.label>First name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="lastName" required="{true}">
<ui:input.root>
<ui:input.label>Last name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</primitives:fieldArray.itemTemplate>
<ui:fieldArray.itemGroup>
<f:for each="{people}" as="person" iteration="it">
<ui:fieldArray.item index="{it.index}">
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="firstName" defaultValue="{person.firstName}" required="{true}">
<ui:input.root>
<ui:input.label>First name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="lastName" defaultValue="{person.lastName}" required="{true}">
<ui:input.root>
<ui:input.label>Last name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</f:for>
<ui:fieldArray.emptyState>No people added yet.</ui:fieldArray.emptyState>
</ui:fieldArray.itemGroup>
<div {ui:ref(name: 'status', context: 'fieldArray')} class="text-sm text-muted-foreground">{peopleCount} of {maxPeople} added ({maxPeople - peopleCount} remaining)</div>
<ui:fieldArray.addTrigger asChild="{true}">
<ui:button variant="secondary">Add person</ui:button>
</ui:fieldArray.addTrigger>
</ui:fieldArray.root>
Full Form With Client-Side Validation
Permalink to heading "Full Form With Client-Side Validation"A complete Form wrapping a FieldArray of guests, each with a name/email pair - required fields and the email format are validated live as you type or blur, using a Zod schema passed straight to validation. z.array(z.object({...})) covers however many guest rows currently exist (added or removed) without FieldArray needing to know about validation at all - each issue's own path (e.g. ['guests', 0, 'email']) is matched back to the exact row's own field automatically. Submission is blocked until every row is valid.
<ui:exposeToClient />
<ui:form.root actionUri="#" controlled="{true}" rootId="{rootId}-form" class="w-full max-w-md">
<ui:form.content class="space-y-6">
<ui:fieldArray.root name="guests" itemCount="1">
<primitives:fieldArray.itemTemplate>
<ui:fieldArray.item>
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="name" required="{true}">
<ui:input.root>
<ui:input.label>Name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="email" required="{true}">
<ui:input.root type="email">
<ui:input.label>Email</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</primitives:fieldArray.itemTemplate>
<ui:fieldArray.itemGroup>
<ui:fieldArray.item index="0">
<div class="grid grid-cols-2 gap-3">
<ui:field.root name="name" required="{true}">
<ui:input.root>
<ui:input.label>Name</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
<ui:field.root name="email" required="{true}">
<ui:input.root type="email">
<ui:input.label>Email</ui:input.label>
<ui:input.input />
</ui:input.root>
<ui:field.error />
</ui:field.root>
</div>
<ui:fieldArray.removeTrigger asChild="{true}">
<ui:button variant="ghost">Remove</ui:button>
</ui:fieldArray.removeTrigger>
</ui:fieldArray.item>
</ui:fieldArray.itemGroup>
<ui:fieldArray.addTrigger asChild="{true}">
<ui:button variant="secondary">Add guest</ui:button>
</ui:fieldArray.addTrigger>
</ui:fieldArray.root>
<ui:button type="submit">Send invitations</ui:button>
</ui:form.content>
<ui:form.indicator
state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Success')}"
class="space-y-3">
<ui:form.successText class="block">Invitations sent!</ui:form.successText>
<ui:button type="reset">Reset</ui:button>
</ui:form.indicator>
</ui:form.root>
<vite:asset entry="EXT:docs/Resources/Private/Components/GuestList/GuestList.entry.ts" />
import { getHydrationData, mountAll } from 'fluid-primitives';
import { Form } from 'fluid-primitives/form';
import { z } from 'zod';
// Every guest row's own name/email fields are already validated live by the Field/Input
// primitives nested inside FieldArray - Zod just describes what "valid" means per row. Its own
// issue path (e.g. ['guests', 0, 'name']) is matched back to the exact field name Field itself
// renders (`guests[0][name]`) automatically, however many rows currently exist (added or removed
// client-side) - no fixed field list needed.
const guestListSchema = z.object({
guests: z.array(
z.object({
name: z.string().min(1, 'Name is required.'),
email: z.email('A valid email address is required.'),
})
),
});
mountAll('ui:guestList', ({ props }) => {
const data = getHydrationData('ui:form', props.id + '-form');
if (!data) return;
const form = new Form({
...data.props,
validation: guestListSchema,
onSubmit: async ({ values }) => {
alert(JSON.stringify(values.toObject()));
return true;
},
});
form.init();
});
API Reference
Permalink to heading "API Reference"The following tables cover the available props of the Fluid Primitives.
fieldArray.root
Contains every part of the field array. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
name | stringThe base field name for this array's rows, e.g. `people` - each row's nested fields are automatically prefixed with it and their row index, producing names like `people[0][firstName]`. | Yes | - |
itemCount | integerNumber of rows rendered server-side, e.g. `itemCount=""`. Lets `emptyState`/`addTrigger`/`removeTrigger` reflect the correct hidden/disabled state before JavaScript hydrates, instead of only afterward. | Yes | - |
minItems | integerMinimum number of rows required - once exactly this many remain, removing a row is disabled. | No | - |
maxItems | integerMaximum number of rows allowed - once this many exist, adding a row is disabled. | No | - |
translations | arrayLocalized screen-reader announcements for adding/removing a row. A `%number%` placeholder is replaced with the row's 1-based position. Set an entry to `` to disable that announcement, or (client-side only, via the FieldArray constructor) pass a callback to build the message from the row's own field values instead. Use `f:translate` for per-template localization overrides when needed. | No | - |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
rootId | stringThe root ID of the component, used for hydration and identification. | No | - |
ids | arrayThe IDs of of the component parts for composition. | No | [] |
controlled | booleanIf true, the component is meant to be initialized manually inside another component | No | false |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fieldArray.itemTemplate
Wraps one row's markup, authored once and cloned client-side for each added row. Renders a <template> element - never visible itself.
fieldArray.itemGroup
Groups every row, existing and added. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fieldArray.item
One row. Existing rows are authored directly with an index; added rows are cloned from itemTemplate, which omits it. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
index | integerThis row's position in the array, e.g. `0`. Required for a real row; omitted inside `itemTemplate`'s stencil, where the client fills in a real index on each clone. | No | - |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fieldArray.emptyState
Shown while itemGroup has no rows yet. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fieldArray.addTrigger
Appends a new row, cloned from itemTemplate. Renders a <button> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fieldArray.removeTrigger
Removes its enclosing row and re-indexes every later row down by one. Renders a <button> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
<primitives:fieldArray.root itemCount="{items -> f:count()}">
<primitives:fieldArray.itemTemplate>
<primitives:fieldArray.item>
<!-- Your row's fields here -->
<primitives:fieldArray.removeTrigger />
</primitives:fieldArray.item>
</primitives:fieldArray.itemTemplate>
<primitives:fieldArray.itemGroup>
<f:for each="{items}" as="item">
<primitives:fieldArray.item index="{...}">
<!-- Your row's fields here -->
<primitives:fieldArray.removeTrigger />
</primitives:fieldArray.item>
</f:for>
<primitives:fieldArray.emptyState />
</primitives:fieldArray.itemGroup>
<primitives:fieldArray.addTrigger />
</primitives:fieldArray.root>