Skip to content
Domphy

Field Arrays & Nested Forms

Basic array field

Use dot-bracket notation for array indexes. Create an array-field handle and use its built-in array helpers (pushValue, removeValue, swapValues, etc.). form.field(name) is cached by name, so calling it again for the same index inside a reactive render callback (as below) reuses the existing field handle instead of mounting a new one:

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

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

// Array-level field handle gives access to push/remove helpers
const tags = form.field<string[]>("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: () => tags.removeValue(i),
            },
          ],
        }
      }),
    },
    {
      button: "Add Tag",
      type: "button",
      $: [button()],
      onClick: () => tags.pushValue(""),
    },
    {
      button: "Save",
      type: "submit",
      $: [button({ tone: "shift-1" })],
    },
  ],
  onSubmit: (e) => { e.preventDefault(); form.handleSubmit() },
}

Field-level array helpers

MethodDescription
field.pushValue(item)Append an item to the array
field.insertValue(index, item)Insert at a specific index
field.replaceValue(index, item)Replace item at an index
field.removeValue(index)Remove item at an index
field.swapValues(a, b)Swap two items (preserves field meta)
field.moveValue(a, b)Move item from index a to index b
field.clearValues()Remove all items

These methods live on a FieldHandle<T[]> — create the handle for the array field itself, then call helpers on it. Per-item fields are separate handles with the [i] path suffix.

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 contacts = form.field<Array<{ name: string; email: string }>>("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: () => contacts.removeValue(i),
      },
    ],
  }
}

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

Reordering with swapValues / moveValue

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

// Swap with the item above
{
  button: "↑",
  type: "button",
  disabled: i === 0,
  onClick: () => contacts.swapValues(i, i - 1),
}

// Move item from index 2 to index 0
contacts.moveValue(2, 0)

swapValues(a, b) — swaps two elements and preserves per-field state (touched, dirty, errors). moveValue(a, b) shifts intermediate items to fill the gap.

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,
  },
})

Reusable nested sections

Extract repeated nested sections as component functions using dot-path field access:

import { createForm } 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 street = form.field<string>(`${prefix}.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" }] })

Programmatic field control

FormHandle exposes imperative getters and setters without needing to reach into form.form:

// Read a value without a listener
const current = form.getFieldValue("contacts")

// Set a value programmatically (triggers onChange validation)
form.setFieldValue("contacts[0].name", "Alice")

// Trigger validation manually
await form.validateField("contacts[0].email", "change")

Advanced: underlying FormApi / FieldApi

For lower-level control (custom validation runners, direct store access), use the escape hatch:

const rawForm = form.form    // FormApi<TData> — full TanStack form-core API
const fieldApi = field.api   // FieldApi — full form-core field surface