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.

Labels

Override the Add / Remove button labels via the labels prop for i18n:

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

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.

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