react-data-form

Asymmetric forms

Page builders and résumés: a form whose shape its own content decides.

Most forms are symmetric: the fields are known when the description is written, and every submission has the same shape as the last one. A contact form is five fields, an invoice is a customer and an address, and the description can list them.

// Symmetric: five fields, always the same five, in the same order.
const contactForm = {
  inputs: {
    name: { label: "Name", required: true },
    email: { label: "Email", controller: EmailInputController },
    subject: { label: "Subject" },
    message: { label: "Message", controller: TextAreaInputController },
    consent: { label: "Keep me posted", controller: SwitchInputController },
  },
}

A page builder cannot be written that way. One page is a hero followed by two paragraphs; the next is a gallery, a quote and a button. The fields are not a property of the form — they are a property of what the reader decided to put on the page, two minutes ago, in an order nobody knew in advance. That is an asymmetric form: its shape is decided by its content.

What makes it asymmetric#

Three things, and they always come together. The document is a list rather than a record. Its entries are of different types, each with its own fields. And the reader chooses which types, how many, and in which order.

The library answers all three with one field. A block type is a form of its own, registered in the form registry; a single array field offers them as a palette, and holds whichever ones were picked. Nothing about the page is written down anywhere — only the vocabulary it can be written in.

Not the same thing as one form per action

The view package documents a form per action — creating an article asking for two fields where editing it asks for six. That is a resource showing a different face per screen. This page is about a single form whose content has no fixed shape at all. The two compose, and neither implies the other.

Where it comes up#

  • Page builders. The case that names the feature: a marketing page assembled out of hero, text, image, gallery, quote and call-to-action blocks, by someone who will never open the codebase.
  • Résumés. An identity, then a career of unequal length, then diplomas, then a wall of skills — four section types, repeated as often as a life required, in whatever order reads best.
  • Catalogue sheets. A product whose specification depends on its category: a table of sizes for a shirt, a datasheet for a drill, an allergen list for a jar of jam.
  • Anything with an editorial structure. Newsletters, questionnaires, course chapters, contract clauses. If the answer to "what are the fields?" is "it depends on the document", the form is asymmetric.

Describing the blocks#

Each block type is an ordinary form description, handed to addForm under an identifier. What makes it a block is its @for tags, and the order of those tags matters.

blocks.ts
import {
  addForm,
  SelectInputController,
  WysiwygInputController,
} from "react-data-form"
import { AlignLeft, Sparkles } from "lucide-react"

// The first tag identifies the block: it is stored on the item as `type`, and
// it is what resolves that item back to this form. The tags after it classify
// it — "page-block" is the one the palette selects on.
addForm("page.hero", {
  name: "Hero",
  icon: Sparkles,
  "@for": ["page.hero", "page-block"],
  inputs: {
    title: { label: "Title", required: true },
    subtitle: { label: "Subtitle" },
    image: {
      label: "Backdrop",
      controller: SelectInputController,
      valueOptions: MEDIA_LIBRARY,
    },
  },
})

addForm("page.text", {
  name: "Text",
  icon: AlignLeft,
  "@for": ["page.text", "page-block"],
  inputs: {
    title: { label: "Heading" },
    body: { label: "Body", controller: WysiwygInputController },
  },
})

The first tag has to be unique

@for[0] is what a block stores as its type, and what the registry resolves that type back through. Give every block the same first tag — ["page-block"] alone, say — and every block on the page resolves to whichever form was registered first: one block type, repeated, whatever the reader picked.

name is the label the palette and the block header show, and icon is the glyph above it. Both are optional; without them a block is announced by its identifier, which is nobody's idea of a nice palette.

The field that holds them#

One field, of the whole document. createFormArrayInputController builds it: forms turns it into a palette, draggable lets the reader reorder what they have added, and closedByDefault decides whether the blocks come up folded.

pageForm.ts
import { createFormArrayInputController } from "react-data-form"

const pageForm = {
  label: { title: "Landing page", submit: "Publish" },
  inputs: {
    blocks: createFormArrayInputController({
      label: "Content",
      // The palette: every form carrying that tag, and nothing else.
      forms: ["page-block"],
      draggable: true,
      // A twenty-block page opens on its outline, not on its first paragraph.
      closedByDefault: true,
    }),
  },
}
NameTypeDefaultDescription
formsstring[]—The @for tags the palette offers. An explicit empty array offers every registered form; omitting the key drops the palette altogether and adds blank blocks based on form.
draggableboolean—Adds the grip to each block header, and reorders on drop.
closedByDefaultbooleanfalseMounts the list folded, headers only. It holds for the blocks added later too — except the one just inserted, which opens, so that clicking Add shows something.
identifierKeystring"order"The key the position is written to, and the one the list is sorted on.
minnumber—Refuses to add past that many blocks, with a message.
formFormInterface—The single block shape, for a repeater with no palette to choose from.

Whatever that initial state is, every block header carries a chevron that folds its sub-form away on its own. The grip, the position and the block's name stay in place, so a page of twenty blocks is read as an outline and reordered without scrolling through the prose of each one.

The builder, running#

Two documents, one field. Add a block, drag it, type in it — and watch the result on the right redraw. Nothing below is written per block type except the drawing itself.

The card is the application’s, not the library’s

The two demos below hand the field a controller of their own — the property every input description carries — so the outline shows what each block says rather than what it is: the title read off the content, a thumbnail of the picture it holds, the palette opening between two blocks instead of at the end. The value going in and out is the array described here, unchanged, which is exactly what makes the swap possible: createFormArrayInputController is a default, not a requirement.

Three blocks to start with — add, reorder, type

The same field, with forms: ["resume-block"] and four other block types registered, is a résumé editor:

An identity and a first position — the palette holds four

Both run at full size, side by side, in the playground.

What comes out#

An array of plain objects, each carrying the fields of its own block type plus three keys the controller maintains: id, type and order.

{
  "blocks": [
    {
      "id": "k3f9",
      "type": "page.hero",
      "order": 0,
      "title": "Coffee, from the farm to your kitchen",
      "image": "sunrise"
    },
    {
      "id": "p1a2",
      "type": "page.text",
      "order": 1,
      "title": "What we do",
      "body": "<p>We roast in small batches…</p>"
    }
  ]
}

That is the shape to store — one JSON column, or one API Platform sub resource. Read it back into the form as the field's value and the blocks come up as they were left, in the order order gives them.

Drawing the result#

The library stops at the payload: it knows how to ask for a hero, not what a hero looks like. The half you write is a switch on type, which is also where the design system of the site belongs.

Page.tsx
function Block({ block }: { block: BuilderBlock }) {
  switch (block.type) {
    case "page.hero":
      return <Hero block={block} />
    case "page.text":
      return <Text block={block} />
    case "page.gallery":
      return <Gallery block={block} />
    default:
      // A type the application does not draw yet: skipped, not crashed.
      return null
  }
}

export function Page({ blocks }: { blocks: BuilderBlock[] }) {
  return blocks.map((block) => <Block key={block.id} block={block} />)
}

A default branch that returns nothing is worth keeping: a page saved against a block type that has since been retired then renders without it, instead of taking the page down.

Things worth knowing#

  • The registry is a singleton. addForm writes into it, so block types are registered once at module scope — registering the same block from two components offers it twice in the palette.
  • Blocks save as they are typed. Each block is a sub-form, and a sub-form sets saveOnChange: values reach the array as they are entered, not on an inner submit. There is one submit button for the whole document.
  • order is data, not position. The list is sorted on it, the header shows it, and dragging rewrites it across every block. Store it, or the page comes back shuffled.
  • Folding is not data. Which blocks are open is display state, held by the field and forgotten when the form remounts. It never reaches onChange, so nothing about it is stored — a block folded and a block open save exactly the same payload.
  • A block can nest. Nothing stops a block type from carrying an array field of its own — a section holding its own columns. The reader pays for it in depth, so it is worth being sure the document really is that deep.

The mechanics of the array field — a field that is a form, a recursive shape, a list of plain scalars — are on Nested forms & arrays.