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.

pageForm.ts
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,
  }),
}
NameTypeDefaultDescription
formsstring[]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.
draggablebooleanLets the reader reorder the blocks by dragging.
identifierKeystring"order"The key the ordering is written to on each block.
formFormInterfaceThe 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. FormInputController sets saveOnChange on 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.