Skip to content

Repeaters & tables

A repeater is a field whose value is an array of row objects, where each row renders a shared field template (columnFields) defined by the backend. The engine owns row identity, add/remove/reorder, scoped validation and expressions, and per-row render isolation. You own the layout — once.

data.directors = [
{ fullName: 'Asha', sharePct: '60' },
{ fullName: 'Vikram', sharePct: '40' },
]

The stored shape is the submit payload — no assembly, no internal bookkeeping in the data (row IDs live beside it, never inside it).


{
"name": "directors",
"type": "repeater", // any name — you choose what to register
"label": "Director",
"order": 1,
"minRows": 1, // structural floor (removeRow refuses below)
"maxRows": 5, // structural ceiling (addRow refuses above)
"initialRows": 1, // seeded rows when no value is sent
"validation": [ // repeater-LEVEL rules — get the whole array
{ "name": "sharesTotal100", "message": "Shares must total 100%" }
],
"columnFields": [ // the row template — ordinary fields,
{ // containers included
"name": "fullName", "type": "text", "order": 1,
"required": true,
"validation": [{ "name": "required", "message": "Name is required" }]
},
{ "name": "sharePct", "type": "text", "order": 2 },
{
"name": "nomineeName", "type": "text", "order": 3,
"show": "%hasNominee% == 'yes'" // row-scoped expression
},
{
"name": "din", "type": "text", "order": 4,
"show": "%country% == 'IN'" // form-scope fallback
}
]
}

Seeding precedence when the form loads: value array > initialRows > minRows > 0.

import { useRepeaterField, RepeaterRow } from 'driven-form/react'
import type { FieldComponentProps } from 'driven-form'
function RepeaterField({ field, fieldValue, fieldError, dependencyData }: FieldComponentProps) {
const repeater = useRepeaterField({ field, fieldValue, dependencyData, fieldError })
if (!repeater.isShow) return null
return (
<fieldset>
<legend>{field.label}</legend>
{repeater.rows.map((row) => (
<div key={row.id}>
<RepeaterRow repeater={repeater} row={row} />
{/* extras are SIBLINGS of RepeaterRow — see "Render isolation" */}
<button onClick={() => repeater.removeRow(row.id)} disabled={!repeater.canRemoveRow}>
</button>
</div>
))}
<button onClick={() => repeater.addRow()} disabled={!repeater.canAddRow}>
+ Add {field.label}
</button>
{repeater.fieldError && <p className="error">{repeater.fieldError}</p>}
</fieldset>
)
}
registry.register('repeater', RepeaterField)

RepeaterRow renders the row template through the engine, inside that row’s scope, using the registry inherited from the outer renderer — your existing leaf components (text, select, …) and containers work inside rows unchanged.

Working reference: examples/basic/repeater (Cap Table).

ConcernBehaviour
Row identityStable generated IDs (row.id) — safe React keys; survive remove/reorder
ValidationRow rules register under scoped keys (directors.r3.fullName); errors land on the right row; submit blocks on them; scroll-to-error reaches into the row
Repeater-level rulesOrdinary validators on the repeater — they receive the rows array ((rows) => rows.length > 1 ? null : 'Need two+')
required: true on the repeaterAt least one row must exist ([] counts as empty)
Row removalUnregisters the row’s rules and sweeps its errors
Hide (show false)Whole subtree unmounts and the value clears to []
Expressions in rowsRow-local names first (%sharePct% = this row’s), unknown names fall back to form scope (%country%). Shadowing is row-wins.
Calculated values in rowscalculateValue: "%qty% * 2" computes per-row
Render isolationTyping in row k re-renders row k’s field — not other rows, not other fields (enforced by tests)

RepeaterRow is memoized. Keep per-row extras (remove buttons, drag handles) as siblings of RepeaterRow, not children — inline children would change identity every render and defeat the memo. The sibling button re-rendering is a single cheap DOM node; the row’s field subtree bailing is what matters.

Repeaters nest: a repeater-typed field inside columnFields gets its own scope on top of the row’s scope. Keys chain (directors.r1.addresses.r0.city) and data stays plainly nested arrays/objects.

  • Field and repeater names must not contain . — it is the scope separator.
  • Custom validators on row fields receive the root form data as their second argument (not the row object). Prefer row expressions for row-local conditions.
  • isValidating (async validator spinners) is not yet sliced per-row.
  • All rows share one template; vary row appearance with row-scoped show expressions. Discriminated row templates are a possible future addition.
  • Aggregates across rows from inside a row are not supported; whole-array computation belongs to repeater-level validators today and to cross-row aggregate expressions (v2) tomorrow.

RepeaterRow is a convenience. The same composition is available manually:

<BlockFieldProvider value={repeater.getRowScope(row.id)}>
<RenderDynamicFields tree={repeater.rowTemplate} data={{ ...repeater.externalDeps, ...row.values }} errors={row.errors} />
</BlockFieldProvider>

Nothing RepeaterRow does is unreachable — it just also handles memoization and focus registration for you.