Arrays

Lists with one entry per item, their controls, and the frame around them.

An array entry expands a list: each item gets the entry you describe under item, at its own path.

const  = .({
  : .(.({ : .(), : .() })),
})

export const  = createForm({
  ,
  : { contacts: [] },
  fields: {
    contacts: {
      : () => ({ : '', : '' }),
      : 'Add contact',
      : (, ) => `Remove ${. || `contact ${ + 1}`}`,
      : {
        : {
          name: { : 'text', : { : 'Name' } },
          email: { : 'text', : { : 'Email' } },
        },
      },
    },
  },
})

The inputs land at contacts[0].name, contacts[0].email, contacts[1].name and so on.

KeyRequiredMeaning
itemyesThe entry for one item, in any of the four shapes.
newItemyesBuilds a new, empty item. The schema does not say what empty means, so you do.
addLabelyesThe accessible name of the add control.
removeLabelyesThe accessible name of each remove control. A function of the item and its index names what it removes.
itemKeynoThe identity of an item, so its widgets keep their state when the list is reordered.
propswhen the frame requires themProps for the registered array frame.

Name each remove control after what it removes. Three buttons all called "Remove" tell a screen reader nothing about which contact goes.

Keys and reordering

Without itemKey, items are keyed by their position. Values always follow their item, but what a widget holds itself, such as an open popover, focus or an uncontrolled input, stays at the old position when items move. Give items an identity as soon as they can be reordered:

const  = .({ : .(.({ : .(), : .() })) })

export const  = createForm({
  ,
  : { steps: [] },
  fields: {
    steps: {
      : () => .,
      : () => ({ : .(), : '' }),
      : 'Add step',
      : (, ) => `Remove step ${ + 1}`,
      : { : { title: { : 'text', : { : 'Step' } } } },
    },
  },
})

The default controls

Without a registered frame, formdef renders the items, a remove button after each, and one add button at the end. There is no wrapper element; the buttons carry data-formdef-remove and data-formdef-add, so your CSS can reach them:

<!-- the fields of contacts[0] -->
<button type="button" data-formdef-remove>Remove Ada</button>
<!-- the fields of contacts[1] -->
<button type="button" data-formdef-remove>Remove Grace</button>
<button type="button" data-formdef-add>Add contact</button>

A frame of your own

A registry takes one array frame under structure.arrayFrame, and it frames every array entry of every form built on that registry. It receives ArrayFrameProps: the array field, one key per item, a function that renders an item, the resolved labels and TanStack's array operations.

form/array-frame.tsx
import type {  } from '@formdef/core'

type  = { : string }

export const  = <,>({
  ,
  ,
  ,
  ,
  ,
  ,
  ,
}: <> & ) => (
  <section aria-label={}>
    <ul>
      {.((, ) => (
        <li ={}>
          {()}
          <button type="button" onClick={() => ()}>
            {[]}
          </button>
        </li>
      ))}
    </ul>
    <button type="button" onClick={}>
      {}
    </button>
  </section>
)

Register it, and every array entry passes the frame's own props through props, checked against the frame:

const { createForm } = ({
  ,
  ,
  : { :  },
  : { :  },
})

export const  = createForm({
  : .({ : .(.()) }),
  : { members: [] },
  fields: {
    members: {
      : () => '',
      : 'Add member',
      : () => `Remove ${ || 'member'}`,
      : { : 'Team members' },
      : { : 'text', : { : 'Name' } },
    },
  },
})

The frame renders again when items are added, removed or moved, when a key or a remove label changes, and when the list itself gets an error; typing inside an item does not render it again. A frame that shows values of its items, such as a total, subscribes to them itself, for instance with TanStack's useStore(field.store, selector).

Besides add and remove, a frame receives insert, move, swap, replace and clear, so it can offer reordering. insert builds the item with newItem when it gets none.

  • Errors about the whole list. A rule on the list itself, such as a minimum length, reports on the array field. The frame receives it as field and is the only place that can show it.
  • Props for an item's widget. renderItem(index, extra) passes extra to the widget of the item, for instance to label each one "Contact 2". This reaches a widget only when the item is a leaf, or an object whose only field is a leaf and which has no layout. Anywhere else there is no single widget to receive it; formdef drops it and warns once in development.

A registry has exactly one frame. An array that needs something else renders itself, with a custom entry on TanStack's array field:

export const  = createForm({
  : .({ : .(.()) }),
  : { links: [] },
  fields: {
    links: {
      : 'array',
      : () => (
        <div>
          <p>{.state..} links</p>
          <button type="button" onClick={() => .('https://')}>
            Add link
          </button>
        </div>
      ),
    },
  },
})