Tier 2 · CommonRecipe #8

Dynamic Fields with useFieldArray

Add and remove array items at runtime, validate each row independently, and submit the full typed array to your server action.

Why it matters

Dynamic lists — invoice line items, contact addresses, team members, skill tags — are one of the most common patterns in real-world forms. React Hook Form's useFieldArray is the standard solution, but integrating it with useActionForm has nuances: the control object must come from the hook, arrays don't play well with FormData actions, and per-item error paths have a specific shape.

This recipe shows a complete address list form with add, remove, per-field errors, and a withZod server action that receives and validates the full typed array.

Full Example — Address List

actions.ts
'use server'
import { z } from 'zod'
import { withZod } from 'hookform-action-core/with-zod'

const addressSchema = z.object({
  addresses: z
    .array(
      z.object({
        street:  z.string().min(1, 'Street is required'),
        city:    z.string().min(1, 'City is required'),
        country: z.string().min(2, 'Select a country'),
      })
    )
    .min(1, 'Add at least one address'),
})

export const saveAddressesAction = withZod(addressSchema, async (data) => {
  // data.addresses is typed as { street: string; city: string; country: string }[]
  await db.addresses.replaceAll(data.addresses)
  return { success: true }
})
address-form.tsx
'use client'
import { useFieldArray } from 'react-hook-form'   // from RHF, not hookform-action
import { useActionForm } from 'hookform-action'
import { saveAddressesAction } from './actions'

type AddressValues = {
  addresses: { street: string; city: string; country: string }[]
}

const EMPTY_ADDRESS = { street: '', city: '', country: '' }

export function AddressForm() {
  const {
    register,
    control,          // ← pass to useFieldArray
    handleSubmit,
    formState: { errors, isPending, isSubmitSuccessful },
  } = useActionForm<AddressValues>(saveAddressesAction, {
    defaultValues: { addresses: [EMPTY_ADDRESS] },
  })

  // useFieldArray reads and writes through the same RHF control
  const { fields, append, remove, move } = useFieldArray({
    control,
    name: 'addresses',
  })

  if (isSubmitSuccessful) {
    return <p className="text-green-400">Addresses saved!</p>
  }

  return (
    <form onSubmit={handleSubmit()}>
      <div className="space-y-4">
        {fields.map((field, index) => (
          // IMPORTANT: use field.id as key — not the array index
          <div key={field.id} className="border border-gray-800 rounded-lg p-4">
            <div className="flex items-center justify-between mb-3">
              <span className="text-sm font-medium text-gray-400">
                Address {index + 1}
              </span>
              {fields.length > 1 && (
                <button
                  type="button"
                  onClick={() => remove(index)}
                  className="text-sm text-red-400 hover:text-red-300"
                >
                  Remove
                </button>
              )}
            </div>

            <div className="space-y-3">
              <div>
                <label className="text-xs text-gray-500">Street</label>
                <input
                  {...register(`addresses.${index}.street`)}
                  placeholder="123 Main St"
                />
                {errors.addresses?.[index]?.street && (
                  <p className="text-red-400 text-sm mt-1">
                    {errors.addresses[index].street?.message}
                  </p>
                )}
              </div>

              <div className="grid grid-cols-2 gap-3">
                <div>
                  <label className="text-xs text-gray-500">City</label>
                  <input
                    {...register(`addresses.${index}.city`)}
                    placeholder="New York"
                  />
                  {errors.addresses?.[index]?.city && (
                    <p className="text-red-400 text-sm mt-1">
                      {errors.addresses[index].city?.message}
                    </p>
                  )}
                </div>

                <div>
                  <label className="text-xs text-gray-500">Country</label>
                  <select {...register(`addresses.${index}.country`)}>
                    <option value="">Select…</option>
                    <option value="US">United States</option>
                    <option value="GB">United Kingdom</option>
                    <option value="BR">Brazil</option>
                  </select>
                  {errors.addresses?.[index]?.country && (
                    <p className="text-red-400 text-sm mt-1">
                      {errors.addresses[index].country?.message}
                    </p>
                  )}
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>

      {/* Array-level error (e.g. "Add at least one address") */}
      {errors.addresses?.root && (
        <p className="text-red-400 text-sm mt-2">{errors.addresses.root.message}</p>
      )}

      <div className="flex gap-3 mt-6">
        <button
          type="button"
          onClick={() => append(EMPTY_ADDRESS)}
          className="text-sm text-brand-400 hover:text-brand-300"
        >
          + Add address
        </button>

        <button type="submit" disabled={isPending} className="ml-auto">
          {isPending ? 'Saving…' : 'Save Addresses'}
        </button>
      </div>
    </form>
  )
}

Key Concepts

useFieldArray requires control

useFieldArray is from react-hook-form (not this library). It needs the control object from useActionForm. You cannot use it with the standalone register spread pattern.

field.id as key (not index)

useFieldArray generates stable IDs for each row. Always use field.id as the React key, not the array index. Index-based keys cause incorrect animations and state mismatches when rows are removed.

register(`name.${index}.field`) — dot notation

Use template literal dot-notation to register nested array fields. RHF will collect them into a properly structured array in getValues() and on submit.

Use withZod (JSON action), not FormData

FormData doesn't natively support nested arrays. Always use withZod (which sends JSON) for forms with useFieldArray. If you must use a FormData action, serialize the array manually with JSON.stringify.

⚠️ Pitfalls

  • Using the array index as key

    When you remove item at index 1 from [0, 1, 2], React reuses the DOM node for the new index 1 (previously index 2). Input values get mixed up. Always use field.id.

  • Per-item errors path typo

    The errors path is errors.addresses?.[index]?.street?.message — note the optional chaining at each level. Missing any level causes a runtime error when there are no errors.

  • EMPTY_ADDRESS defined inside the component

    Define the empty item template outside the component (or with useCallback / useMemo). An inline object creates a new reference on every render and can cause unexpected append behaviour.

Related