Skip to main content

Array Fields

z.array(z.object(...)) fields are automatically rendered as a repeating group. Each row is an independent nested form segment rendered below an Add button.

Object arrays only

UniForm renders array fields whose item schema is a z.object(...). Arrays of primitives (e.g. z.array(z.string())) are not rendered as repeating fields — use a custom component for those cases.

const schema = z.object({
members: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['owner', 'member', 'guest']),
}),
),
})

Row controls

By default each row gets:

ControlBehaviour
RemoveRemoves that row from the array
Move Up / Move DownReorders rows

Enable Duplicate and Collapse per-row via the fields prop:

<AutoForm
fields={{
members: { duplicable: true, collapsible: true },
}}
...
/>

You can also replace the entire row layout via layout.arrayRowLayout. The component receives children (the row's fields) and a buttons object containing pre-rendered button nodes — place them wherever you like:

const MyRowLayout = ({ children, buttons, index }) => (
<div className='array-row'>
{buttons.collapse}
{children}
<div className='row-controls'>
{buttons.moveUp}
{buttons.moveDown}
{buttons.duplicate}
{buttons.remove}
</div>
</div>
)

See ArrayRowLayoutProps for the full type.

Button components

Swap the button components for all array actions in one shot by passing layout.arrayButtons. Set base as the fallback for every slot, then override individual actions as needed.

import { Button } from 'my-design-system'

// Use your DS button for every array action
<AutoForm
layout={{ arrayButtons: { base: Button } }}
...
/>

// Or override slots individually
<AutoForm
layout={{
arrayButtons: {
base: Button,
remove: DangerButton, // only the Remove button uses DangerButton
},
}}
...
/>

Resolution order: specific slot → base → built-in default.

For array button slots, undefined means "use fallback/default" and null means "omit this button".

// Omit all unspecified array action buttons
<AutoForm layout={{ arrayButtons: { base: null } }} ... />

// Omit just Remove
<AutoForm layout={{ arrayButtons: { remove: null } }} ... />

The collapse toggle uses ArrayCollapseButtonProps which adds isCollapsed: boolean. Strip it before forwarding to a DOM element:

const MyCollapseBtn = ({ isCollapsed, ...props }: ArrayCollapseButtonProps) => (
<Button variant={isCollapsed ? 'outline' : 'ghost'} {...props} />
)

<AutoForm layout={{ arrayButtons: { collapse: MyCollapseBtn } }} ... />

Add button position

By default the Add button appears below the rows. Override layout.arrayFieldLayout to change this:

const AddFirstLayout = ({ rows, addButton }: ArrayFieldLayoutProps) => (
<div>
{addButton}
{rows}
</div>
)

<AutoForm layout={{ arrayFieldLayout: AddFirstLayout }} ... />

See ArrayFieldLayoutProps for the full type.

External controls with useArrayField

When you need array actions outside the array field block (for example in a toolbar above the form), use useArrayField(fieldName) inside any component rendered under <AutoForm>.

The hook returns all useFieldArray actions (append, remove, move, swap, replace, etc.) plus:

  • rowCount — current number of rows
  • canAddfalse when the array reached Zod .max(...)
  • atMintrue when row count is at or below Zod .min(...)
import { AutoForm, createForm, useArrayField } from '@uniform-ts/core'

const schema = z.object({
lineItems: z.array(z.object({ name: z.string() })).min(1).max(5),
})

const form = createForm(schema)

function Toolbar() {
const { append, canAdd, rowCount } = useArrayField('lineItems')
return (
<button
type='button'
disabled={!canAdd}
onClick={() => append({ name: '' })}
>
Add item ({rowCount}/5)
</button>
)
}

const FormWithToolbar = ({ children }: FormWrapperProps) => (
<>
<Toolbar />
{children}
</>
)

const RowsOnly = ({ rows }: ArrayFieldLayoutProps) => <>{rows}</>

<AutoForm
form={form}
defaultValues={{ lineItems: [{ name: '' }] }}
layout={{ formWrapper: FormWithToolbar, arrayFieldLayout: RowsOnly }}
onSubmit={console.log}
/>

Use dot paths for nested arrays too (for example "profile.contacts").

See useArrayField() API for the full contract.

Labels

Override individual button labels via the labels prop, or import a ready-made locale pack that covers all strings at once — including accessible aria labels:

// Individual overrides
<AutoForm labels={{ arrayAdd: '+ Add member', arrayRemove: 'Remove' }} ... />

// Full locale pack (only the imported locale is included in your bundle)
import { es } from '@uniform-ts/core/locales/es'

<AutoForm labels={es} ... />

// Locale with per-instance overrides
<AutoForm labels={{ ...es, arrayAdd: '+ Nueva fila' }} ... />

See the Localization guide for the full list of available keys and all bundled locales.

Minimum / maximum items

UniForm respects .min(n) and .max(n) on z.array(...):

z.array(memberSchema)
.min(1, 'At least 1 member required')
.max(10, 'Maximum 10 members')

The Add button is hidden when the max is reached.

Conditional fields inside rows

Fields within each row support the same hidden and condition options as top-level fields. Use setCondition with an "arrayField.fieldName" key — the predicate receives the current row's values, making sibling conditions straightforward:

const taskForm = createForm(schema)

taskForm.setCondition(
'tasks.note',
(row) => row.priority === 'high', // `row` is typed as the item shape
)

Each row evaluates its own condition independently. You can also set hidden: true via the fields prop to permanently suppress a field across all rows:

<AutoForm fields={{ 'tasks.internal': { hidden: true } }} ... />

See the Conditional Fields guide for more detail.

Live Example

Live Editor
const memberSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
  role: z.enum(['owner', 'member', 'guest']),
})

const teamSchema = z.object({
  teamName: z.string().min(1, 'Required'),
  members: z.array(memberSchema).min(1, 'Add at least one member'),
})

const teamForm = createForm(teamSchema)

const CompactRowLayout = ({ children, buttons, index }) => (
  <div
    style={{
      border: '1px solid var(--ifm-color-emphasis-300)',
      borderRadius: 8,
      padding: '0.75rem',
      marginBottom: '0.5rem',
      background:
        index % 2 === 0
          ? 'var(--ifm-color-emphasis-100)'
          : 'var(--ifm-background-color)',
    }}
  >
    {children}
    <div
      style={{
        display: 'flex',
        gap: 8,
        marginTop: 8,
        justifyContent: 'flex-end',
      }}
    >
      {buttons.moveUp}
      {buttons.moveDown}
      {buttons.duplicate}
      {buttons.remove}
    </div>
  </div>
)

function App() {
  const [result, setResult] = React.useState(null)
  return (
    <div style={{ fontFamily: 'system-ui', maxWidth: 480 }}>
      <style>{`.ar-dup { color: var(--ifm-color-primary) } .ar-rm { color: var(--ifm-color-danger) }`}</style>
      <AutoForm
        form={teamForm}
        defaultValues={{ members: [{ name: '', email: '', role: 'member' }] }}
        fields={{
          teamName: { label: 'Team Name' },
          members: { label: 'Team Members', movable: true, duplicable: true },
        }}
        classNames={{ arrayDuplicate: 'ar-dup', arrayRemove: 'ar-rm' }}
        layout={{ arrayRowLayout: CompactRowLayout }}
        labels={{ arrayAdd: '+ Add member', submit: 'Create Team' }}
        onSubmit={(v) => setResult(v)}
      />
      {result && (
        <pre
          style={{
            marginTop: '1rem',
            background: 'var(--ifm-color-emphasis-200)',
            padding: '1rem',
            borderRadius: 6,
            fontSize: 12,
          }}
        >
          {JSON.stringify(result, null, 2)}
        </pre>
      )}
    </div>
  )
}

render(<App />)
Result
Loading...