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).
Schema
Section titled “Schema”{ "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.
The component (written once)
Section titled “The component (written once)”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).
What the engine handles
Section titled “What the engine handles”| Concern | Behaviour |
|---|---|
| Row identity | Stable generated IDs (row.id) — safe React keys; survive remove/reorder |
| Validation | Row 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 rules | Ordinary validators on the repeater — they receive the rows array ((rows) => rows.length > 1 ? null : 'Need two+') |
required: true on the repeater | At least one row must exist ([] counts as empty) |
| Row removal | Unregisters the row’s rules and sweeps its errors |
Hide (show false) | Whole subtree unmounts and the value clears to [] |
| Expressions in rows | Row-local names first (%sharePct% = this row’s), unknown names fall back to form scope (%country%). Shadowing is row-wins. |
| Calculated values in rows | calculateValue: "%qty% * 2" computes per-row |
| Render isolation | Typing in row k re-renders row k’s field — not other rows, not other fields (enforced by tests) |
Render isolation — one rule to keep it
Section titled “Render isolation — one rule to keep it”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.
Nesting
Section titled “Nesting”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.
Constraints & limitations
Section titled “Constraints & limitations”- 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
showexpressions. 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.
Fully custom layouts
Section titled “Fully custom layouts”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.