react-data-form
Nested forms & arrays
Sub-forms, repeatable rows, and the page-builder controller.
Most payloads are not flat. Three mechanisms cover the shapes that come up: a field that is itself a form, a field holding a list of scalars, and a field holding a list of forms.
A field that is a form#
Give a field a form key and it renders as a sub-form. Its values are submitted as a nested object under the field's name — no flattening, no naming convention.
inputs: {
name: { label: "Customer" },
address: {
label: "Billing address",
// A field carrying `form` is rendered as a sub-form…
form: {
inputs: {
street: { label: "Street" },
city: { label: "City" },
zip: { label: "Postcode" },
},
},
},
}
// → { name: "…", address: { street: "…", city: "…", zip: "…" } }A nested address — submit and read the payload
The controller is implicit, but naming it is clearer
A field carrying form falls through to FormInputController. Naming it explicitly, as above, costs one line and makes the intent obvious to the next reader.
Recursive shapes#
form is evaluated when the description is written, which cannot work for a shape that refers to itself — a comment with replies, a category with children. getForm is the lazy version.
inputs: {
child: {
label: "Reply",
// Resolved on render, so a shape can refer to itself.
getForm: () => commentForm,
},
}Repeating a scalar#
For a list of plain values — tags, aliases, email addresses — ArrayInputController is enough. It submits an array of strings, and min and max bound its length.
Type a tag and press Enter
The page builder#
FormArrayInputController holds a list of forms: the reader adds blocks from a palette, fills each one, and reorders them. That is a page builder, and it is about fifteen lines of description.
import { addForm, createFormArrayInputController } from "react-data-form"
// 1. Register the block types. `@for` is the tag the palette filters on.
addForm("block.hero", {
name: "Hero",
"@for": ["page-block"],
inputs: {
title: { label: "Title" },
subtitle: { label: "Subtitle" },
},
})
addForm("block.gallery", {
name: "Gallery",
"@for": ["page-block"],
inputs: { images: { label: "Images", controller: FileInputController } },
})
// 2. One field holds the whole page.
inputs: {
blocks: createFormArrayInputController({
label: "Content",
forms: ["page-block"], // the palette offered on “Add”
draggable: true,
}),
}| Name | Type | Default | Description |
|---|---|---|---|
forms | string[] | — | Which registered forms the palette offers, by their @for tag. An empty array offers every registered form; omitting the key adds a blank block based on form instead. |
draggable | boolean | — | Lets the reader reorder the blocks by dragging. |
identifierKey | string | "order" | The key the ordering is written to on each block. |
form | FormInterface | — | The single block shape, when there is no palette to choose from. |
The palette is fed by the form registry: addForm puts a form in it under an identifier, and the @for tags are how a builder selects the subset it accepts.
Things worth knowing#
- A sub-form saves on change.
FormInputControllersetssaveOnChangeon the inner form, so its values reach the parent as they are typed rather than on an inner submit — there is only ever one submit button. - Violations do not cascade. A validator on an inner field marks that field. Rules spanning the two levels belong in the outer form's own
validator. - The action is inherited. The sub-form is built with the parent's
action, so a field marked create-only behaves the same at both levels.