# Field Array

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

**Reference:** [View Source](https://github.com/jramke/fluid-primitives/tree/main/Resources/Private/Primitives/FieldArray)

**FieldArray.html**

```html
<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>
```

**FieldArray.ts**

```ts
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

- 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 `Field` and any Field-aware primitive (Input, Select, Checkbox, ...) nested inside a row
- Row `name`s use the same bracket notation (`people[0][firstName]`) Extbase and `FormValues` already understand
- Optional `minItems`/`maxItems` bounds - `addTrigger`/`removeTrigger` disable themselves once a bound is reached, and `append()`/`remove()` are no-ops past it
- `emptyState`/`addTrigger`/`removeTrigger` already reflect the correct hidden/disabled state in the server-rendered HTML, from `itemCount`, not only once JavaScript hydrates

## Installation

```bash
typo3 ui:add field-array
```

Or copy the files manually from GitHub (https://github.com/jramke/fluid-primitives.com/tree/main/packages/docs/Resources/Private/Registry/FieldArray) into your project.

Read more about installing [Components and Primitives](/docs/core-concepts/primitives.md).

<div class="relative w-full rounded-lg border px-4 py-3 text-sm grid has-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[&gt;svg]:gap-x-3 gap-y-0.5 items-start [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;svg]:text-current bg-card text-card-foreground not-prose" role="alert" id="alert:«foovCyHaJ5»" data-scope="alert" data-part="root" >

<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5"> <circle cx="12" cy="12" r="10"></circle> <path d="M12 16v-4"></path> <path d="M12 8h.01"></path> </svg>

<div class="col-start-2 min-h-4 font-medium tracking-tight" id="alert:«foovCyHaJ5»:title" data-scope="alert" data-part="title" >

<h3>Also install Field</h3>

</div>

<div class="text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&amp;_p]:leading-relaxed" id="alert:«foovCyHaJ5»:content" data-scope="alert" data-part="content" >

<p>FieldArray only manages rows and indexing - each row's own fields still need the Field primitive (typo3 ui:add field) nested inside.</p>

</div>

</div>

## 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:

```html
<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

`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.

```html
<primitives:fieldArray.root name="people" itemCount="{people -> f:count()}">
    <!-- ... -->
</primitives:fieldArray.root>
```

## Examples

### Starting Empty

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

**FieldArray.html**

```html
<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

`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`.

**FieldArray.html**

```html
<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

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](/docs/core-concepts/forms.md#client-side-validation) 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.

**GuestList.html**

```html
<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" />

```

**GuestList.entry.ts**

```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

### fieldArray.root

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | Yes | `-` | 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]`. |
| `itemCount` | `integer` | Yes | `-` | 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. |
| `minItems` | `integer` | No | `-` | Minimum number of rows required - once exactly this many remain, removing a row is disabled. |
| `maxItems` | `integer` | No | `-` | Maximum number of rows allowed - once this many exist, adding a row is disabled. |
| `translations` | `array` | No | `-` | 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. |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `rootId` | `string` | No | `-` | The root ID of the component, used for hydration and identification. |
| `ids` | `array` | No | `[]` | The IDs of of the component parts for composition. |
| `controlled` | `boolean` | No | `false` | If true, the component is meant to be initialized manually inside another component |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |

### fieldArray.itemTemplate

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |

### fieldArray.itemGroup

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |

### 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 | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `index` | `integer` | No | `-` | 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. |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |

### fieldArray.emptyState

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |

### fieldArray.addTrigger

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |

### fieldArray.removeTrigger

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |

## Anatomy

```html
<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>
```
