Domphy

Field Arrays & Nested Forms

Basic array field

Use dot-bracket notation for array indexes. Manage array items with form.pushFieldValue, form.removeFieldValue, and form.swapFieldValues:

import { createForm } from "@domphy/form/domphy"
import { toState } from "@domphy/core"
import { button } from "@domphy/ui"

const form = createForm<{ tags: string[] }>({
  defaultValues: { tags: [""] },
  onSubmit: ({ value }) => save(value.tags),
})

const App = {
  form: [
    {
      div: (l) => form.values(l).tags.map((_, i) => {
        const field = form.field<string>(`tags[${i}]`, {})
        return {
          _key: i,
          div: [
            {
              input: null,
              type: "text",
              value: (l) => field.value(l),
              onInput: (e) => field.handleChange((e.target as HTMLInputElement).value),
            },
            {
              button: "Remove",
              type: "button",
              $: [button()],
              onClick: () => form.removeFieldValue("tags", i),
            },
          ],
        }
      }),
    },
    {
      button: "Add Tag",
      type: "button",
      $: [button()],
      onClick: () => form.pushFieldValue("tags", ""),
    },
    {
      button: "Save",
      type: "submit",
      $: [button({ tone: "shift-1" })],
    },
  ],
  onSubmit: (e) => { e.preventDefault(); form.handleSubmit() },
}

Array of objects

The same pattern works for arrays of objects. Field names use dot notation for nested keys:

const form = createForm<{
  contacts: Array<{ name: string; email: string }>
}>({
  defaultValues: { contacts: [{ name: "", email: "" }] },
  onSubmit: ({ value }) => save(value.contacts),
})

const ContactRow = (i: number) => {
  const nameField = form.field<string>(`contacts[${i}].name`, {
    validators: { onChange: ({ value }) => value ? undefined : "Required" },
  })
  const emailField = form.field<string>(`contacts[${i}].email`, {
    validators: { onChange: ({ value }) => value.includes("@") ? undefined : "Invalid" },
  })

  return {
    _key: i,
    div: [
      {
        input: null,
        placeholder: "Name",
        value: (l) => nameField.value(l),
        onInput: (e) => nameField.handleChange((e.target as HTMLInputElement).value),
      },
      {
        input: null,
        placeholder: "Email",
        value: (l) => emailField.value(l),
        onInput: (e) => emailField.handleChange((e.target as HTMLInputElement).value),
      },
      {
        button: "✕",
        type: "button",
        onClick: () => form.removeFieldValue("contacts", i),
      },
    ],
  }
}

const App = {
  form: [
    { div: (l) => form.values(l).contacts.map((_, i) => ContactRow(i)) },
    {
      button: "Add Contact",
      type: "button",
      onClick: () => form.pushFieldValue("contacts", { name: "", email: "" }),
    },
  ],
  onSubmit: (e) => { e.preventDefault(); form.handleSubmit() },
}

Reordering with swapFieldValues

Implement drag-to-reorder or up/down buttons:

{
  button: "↑",
  type: "button",
  disabled: i === 0,
  onClick: () => form.swapFieldValues("contacts", i, i - 1),
}

swapFieldValues(fieldName, indexA, indexB) — swaps two elements and preserves per-field state (touched, dirty, errors).

Nested object fields

For deeply nested objects, create sub-fields with the full dot-path:

const form = createForm<{
  address: { street: string; city: string; zip: string }
}>({
  defaultValues: { address: { street: "", city: "", zip: "" } },
  onSubmit: ({ value }) => save(value),
})

const street = form.field<string>("address.street", {
  validators: { onChange: ({ value }) => value ? undefined : "Required" },
})
const city   = form.field<string>("address.city", {})
const zip    = form.field<string>("address.zip", {
  validators: { onChange: ({ value }) => /^\d{5}$/.test(value) ? undefined : "5 digits" },
})

Array field validators

Validate the entire array (e.g. minimum length):

const form = createForm<{ skills: string[] }>({
  defaultValues: { skills: [] },
  validators: {
    onChange: ({ value }) =>
      value.skills.length === 0 ? "Add at least one skill" : undefined,
  },
  onSubmit: ({ value }) => save(value),
})

Or add a validator on the array field itself:

const skills = form.field<string[]>("skills", {
  validators: {
    onChange: ({ value }) =>
      value.length < 1 ? "At least one skill required" : undefined,
  },
})

Field group (FormGroupApi)

FormGroupApi creates a typed sub-form bound to a nested path. Useful for extracting reusable form sections:

import { createForm, FormGroupApi } from "@domphy/form/domphy"

const form = createForm<{ billing: AddressData; shipping: AddressData }>({
  defaultValues: {
    billing: { street: "", city: "", zip: "" },
    shipping: { street: "", city: "", zip: "" },
  },
  onSubmit: ({ value }) => submit(value),
})

function AddressSection(prefix: "billing" | "shipping") {
  const group = form.group<AddressData>(prefix)   // typed to AddressData
  const street = group.field<string>("street", {})
  // group.field("street") is equivalent to form.field("billing.street")

  return {
    fieldset: [
      { legend: prefix === "billing" ? "Billing" : "Shipping" },
      {
        input: null,
        placeholder: "Street",
        value: (l) => street.value(l),
        onInput: (e) => street.handleChange((e.target as HTMLInputElement).value),
      },
    ],
  }
}

Resetting arrays

form.reset() restores the form to defaultValues including all array contents:

{ button: "Reset", type: "button", onClick: () => form.reset() }

Reset to specific values:

form.reset({ contacts: [{ name: "Alice", email: "alice@example.com" }] })

Reading raw FormApi

For advanced use cases (custom validation runners, field state inspection), access the underlying TanStack FormApi:

const rawForm = form.form    // FormApi<TData>
const fieldApi = field.api   // FieldApi<TData, string>

The full TanStack Form v1 API applies to both — see TanStack Form docs for FormApi methods (setFieldValue, getFieldValue, setFieldMeta, validateField, etc.).