Skip to main content

Field Array

View as Markdown

A repeatable group of fields, for letting users add and remove rows like "add another person".

<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>
typo3 ui:add field-array

Read more about installing Components and Primitives.

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.

Inside a Form bound to a persisted object, the form signs the field names of every row rendered on the server into __trustedProperties (people[0][firstName], people[1][firstName], ...). The stencil in itemTemplate is left out, because its index is only known once the client adds the row. Extbase rejects a name that is not signed for a persisted object, so allow the properties of the rows added on the client in the property mapping configuration of the argument, e.g. forProperty('people.*')->allowAllProperties() in the initialize...Action.

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>

A FieldArray with no rows yet - emptyState shows until the first row is added.

No people added yet.

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.

1 of 3 added (2 remaining)

A row that composes components rendering their content through ui:portal: a Select, a DatePicker and a Popover. A portal inside itemTemplate renders in place, wrapped in <div data-portal>, because the stencil is inert. When a row is added, the clone moves the wrapper to where the portal wants to go - the end of <body>, or after the ui:portalContainer of its name - and removing the row removes it again. A ui:portal with disabled stays in the row. The row's entry file mounts the components the clone brought along with mountAll. The row is written once, in an <f:section name="row"> that the stencil and the loop both f:render - a long row does not have to be maintained twice.

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.

The following tables cover the available props of the Fluid Primitives.

Contains every part of the field array. Renders a <div> element.

NameDescriptionRequiredDefault
namestring
The 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
-
itemCountinteger
Number 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
-
minItemsinteger
Minimum number of rows required - once exactly this many remain, removing a row is disabled.
No
-
maxItemsinteger
Maximum number of rows allowed - once this many exist, adding a row is disabled.
No
-
translationsarray
Localized 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
-
attributesarray
Additional attributes that should be rendered on the component where ui:attributes is used.
No
[]
asChildboolean
If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props.
No
-
rootIdstring
The root ID of the component, used for hydration and identification.
No
-
idsarray
The IDs of of the component parts for composition.
No
[]
autoMountboolean
Whether the client initializes the component on its own. Set it to false to mount it yourself with `mount()`
No
true
classstring
The CSS class(es) to be applied to the component.
No
-

Wraps one row's markup, authored once and cloned client-side for each added row. Renders a <template> element - never visible itself.

Groups every row, existing and added. Renders a <div> element.

NameDescriptionRequiredDefault
attributesarray
Additional attributes that should be rendered on the component where ui:attributes is used.
No
[]
asChildboolean
If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props.
No
-
classstring
The CSS class(es) to be applied to the component.
No
-

One row. Existing rows are authored directly with an index; added rows are cloned from itemTemplate, which omits it. Renders a <div> element.

NameDescriptionRequiredDefault
indexinteger
This 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
-
attributesarray
Additional attributes that should be rendered on the component where ui:attributes is used.
No
[]
asChildboolean
If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props.
No
-
classstring
The CSS class(es) to be applied to the component.
No
-

Shown while itemGroup has no rows yet. Renders a <div> element.

NameDescriptionRequiredDefault
attributesarray
Additional attributes that should be rendered on the component where ui:attributes is used.
No
[]
asChildboolean
If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props.
No
-
classstring
The CSS class(es) to be applied to the component.
No
-

Appends a new row, cloned from itemTemplate. Renders a <button> element.

NameDescriptionRequiredDefault
attributesarray
Additional attributes that should be rendered on the component where ui:attributes is used.
No
[]
asChildboolean
If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props.
No
-
classstring
The CSS class(es) to be applied to the component.
No
-

Rendered data attributes

AttributeDescription
data-disabledPresent when disabled

Removes its enclosing row and re-indexes every later row down by one. Renders a <button> element.

NameDescriptionRequiredDefault
attributesarray
Additional attributes that should be rendered on the component where ui:attributes is used.
No
[]
asChildboolean
If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props.
No
-
classstring
The CSS class(es) to be applied to the component.
No
-

Rendered data attributes

AttributeDescription
data-disabledPresent when disabled
data-valueThe index of the row the trigger removes.
NameDescriptionDefault
idstring
The unique identifier of the field array.
-
namestring
The name the rows are nested under, e.g. people for people[0][firstName].
-
itemCountnumber | undefined
The number of rows the server rendered. It sets the state of the triggers before hydration, once the client runs it counts the rows in the DOM instead.
-
minItemsnumber | undefined
The fewest rows allowed. At this many, remove() and the remove triggers do nothing and the remove triggers are disabled.
maxItemsnumber | undefined
The most rows allowed. At this many, append() and the add trigger do nothing and the add trigger is disabled.
-
translations{ rowAdded?: FieldArrayAnnounceTranslation; rowRemoved?: FieldArrayAnnounceTranslation; } | undefined
The screen reader announcements of an added and a removed row. In a string, %number% is replaced with the 1-based position of the row, e.g. Row %number% removed.. A callback builds the message from the data of the row, and may use %number% in what it returns.
-
onItemAdded((detail: { index: number; componentNames: string[] }) => void) | undefined
Called after a row was added by the add trigger, with its index and the client names of the components inside it, e.g. ['field', 'input']. Call their mountAll() here: the field array doesn't know what the template of a row contains.
-
onItemRemoved((detail: { index: number }) => void) | undefined
Called after a row was removed by its remove trigger, with the index it had. The later rows have already moved up by then.
-
NameTypeDescription
getRows() => { index: number }[]The rows that currently exist, by index.
canAppend() => booleanWhether append() would currently add a row, i.e. maxItems hasn't been reached.
canRemove() => booleanWhether remove() would currently remove a row, i.e. more than minItems remain.
append() => voidAdds a row, if maxItems allows it.
remove(index: number) => voidRemoves the row at index, if minItems allows it.