pyreon

Dynamic form arrays — useFieldArray

The pattern

Manage variable-length form inputs with useFieldArray. Each item has a stable monotonic key — use it with <For by> so reordering/insertion preserves input focus and DOM state:

import { useFieldArray, For } from '@pyreon/form'

function TagEditor() {
  const tags = useFieldArray<string>(['typescript', 'signals'])

  // Full mutation surface
  tags.append('reactive')
  tags.prepend('pyreon')
  tags.insert(1, 'framework')
  tags.move(0, 3)
  tags.swap(1, 2)
  tags.replace(['a', 'b', 'c'])
  tags.remove(0)

  // Render — ALWAYS use `by={i => i.key}`, never the index
  return (
    <>
      <For each={tags.items()} by={(item) => item.key}>
        {(item) => (
          <input
            value={() => item.value()}
            onInput={(e) => item.value.set(e.currentTarget.value)}
          />
        )}
      </For>
      <button onClick={() => tags.append('')}>Add tag</button>
    </>
  )
}

Why keys matter

item.key is a monotonically increasing number assigned at insert time — not the array index. Reordering doesn't change keys, so Pyreon's keyed reconciler reuses the same <input> DOM node for the same logical item. Input focus, IME composition, and scroll position all survive the reorder.

Index-based keys defeat the whole design: moving item 0 to index 2 means every input from index 0..2 gets mapped to a DIFFERENT logical item, so a user mid-typing in one field suddenly sees the text from another.

Anti-pattern

// BROKEN — index-based key scrambles focus on reorder
const BadList = () => (
  <For each={tags.items()} by={(_, i) => i}>
    {(item) => <input value={() => item.value()} />}
  </For>
)
function Bad() {
  // BROKEN — stored items array at setup, loses reactivity
  const items = tags.items()
  return <For each={items}>{() => null}</For>
}

function Good() {
  // Correct — read inside a reactive scope
  return (
    <For each={tags.items()} by={(i) => i.key}>
      {() => null}
    </For>
  )
}
// BROKEN — index for deletion is a race condition after reorder
const BadRemove = (i: number) => <button onClick={() => tags.remove(i)}>×</button>

// If the user reordered, the `i` captured at render time no longer
// matches the item they clicked. Use the item identity instead:
const GoodRemove = (item: { key: number }) => (
  <button onClick={() => tags.remove(tags.items().findIndex((x) => x.key === item.key))}>×</button>
)

Cleanest: the useFieldArray API takes a key-or-predicate for mutations. Check get_api({ package: "form", symbol: "useFieldArray" }) for the full surface.

  • Pattern: keyed-lists for the same by={item => item.key} concern on any <For>

  • Pattern: form-fields for the outer form composition

  • Detector: for-missing-by — fires on bare <For each> without by

  • Anti-pattern: "Stable keys in useFieldArray" in form context

Dynamic form arrays — useFieldArray