# Dynamic Field Array

> Start from an empty state, then add and remove teammate rows with per-item validation.

A shadcn/ui block from the [@flx](https://ui.flexnative.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/flx/tsf-advanced-01).

- Page, with a live preview: https://shadcn.exchange/r/flx/tsf-advanced-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/flx/tsf-advanced-01-light-340a76766445e195.png
- Registry item: https://ui.flexnative.com/r/tsf-advanced-01.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/tsf-advanced-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tanstack/react-form`, `lucide-react`, `sonner`, `zod`
- Registry items: `card`, `badge`, `empty`, `field`, `input`, `select`, `button`

## Files

### `registry/forms/tanstack-form/advanced/tsf-advanced-01.tsx`

```tsx
'use client'

import { useState } from 'react'
import { useForm } from '@tanstack/react-form'
import {
  CheckIcon,
  CopyIcon,
  PencilIcon,
  Trash2Icon,
  UserPlusIcon,
  UsersIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import * as z from 'zod'

import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

const ROLES = [
  { value: 'owner', label: 'Owner' },
  { value: 'admin', label: 'Admin' },
  { value: 'member', label: 'Member' },
  { value: 'viewer', label: 'Viewer' },
]

const memberSchema = z.object({
  name: z.string().min(1, 'Name is required.'),
  email: z.email('Enter a valid email.'),
  role: z.string().min(1, 'Pick a role.'),
})

const formSchema = z.object({
  members: z.array(memberSchema).min(1, 'Add at least one teammate.'),
})

type Member = z.infer<typeof memberSchema>
type RowErrors = Partial<Record<keyof Member, string>>

const emptyMember: Member = { name: '', email: '', role: '' }

function initials(name: string) {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return '?'
  return parts
    .slice(0, 2)
    .map((p) => p[0]?.toUpperCase())
    .join('')
}

function roleLabel(value: string) {
  return ROLES.find((r) => r.value === value)?.label ?? value
}

export function TsfAdvanced01() {
  const form = useForm({
    defaultValues: { members: [] as Member[] },
    validators: { onSubmit: formSchema },
    onSubmit: async ({ value }) => {
      toast.success('Invites sent', {
        description: `${value.members.length} teammate(s)`,
      })
    },
  })

  const [editingIndex, setEditingIndex] = useState<number | null>(null)
  const [rowErrors, setRowErrors] = useState<RowErrors>({})

  function commit(index: number) {
    const result = memberSchema.safeParse(form.state.values.members[index])
    if (!result.success) {
      const next: RowErrors = {}
      for (const issue of result.error.issues) {
        const key = issue.path[0] as keyof Member
        if (key && !next[key]) next[key] = issue.message
      }
      setRowErrors(next)
      return
    }
    setRowErrors({})
    setEditingIndex(null)
  }

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle>Invite your team</CardTitle>
        <CardDescription>
          Fill in a teammate, confirm it, then manage each one in place.
        </CardDescription>
      </CardHeader>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (editingIndex !== null) {
            toast.error('Finish the open teammate before sending.')
            return
          }
          form.handleSubmit()
        }}
        className="flex flex-col gap-3"
      >
        <form.Field name="members" mode="array">
          {(membersField) => {
            const members = membersField.state.value

            function startAdd() {
              membersField.pushValue(emptyMember)
              setRowErrors({})
              setEditingIndex(members.length)
            }

            function duplicate(index: number) {
              membersField.pushValue({ ...members[index] })
              setRowErrors({})
              setEditingIndex(null)
            }

            function deleteRow(index: number) {
              membersField.removeValue(index)
              setRowErrors({})
              setEditingIndex(null)
            }

            return (
              <>
                <CardContent className="flex flex-col gap-3">
                  {members.length === 0 ? (
                    <Empty className="border">
                      <EmptyHeader>
                        <EmptyMedia variant="icon">
                          <UsersIcon />
                        </EmptyMedia>
                        <EmptyTitle>No teammates yet</EmptyTitle>
                        <EmptyDescription>
                          Invite people by email and assign each one a role.
                        </EmptyDescription>
                      </EmptyHeader>
                      <EmptyContent>
                        <Button
                          type="button"
                          variant="outline"
                          size="sm"
                          onClick={startAdd}
                        >
                          <UserPlusIcon className="size-4" />
                          Add teammate
                        </Button>
                      </EmptyContent>
                    </Empty>
                  ) : (
                    <>
                      {members.map((member, index) => {
                        if (editingIndex === index) {
                          return (
                            <div
                              key={index}
                              className="bg-muted/30 flex flex-col gap-4 rounded-lg border p-4"
                            >
                              <FieldGroup className="gap-4">
                                <form.Field name={`members[${index}].name`}>
                                  {(field) => (
                                    <Field data-invalid={!!rowErrors.name}>
                                      <FieldLabel htmlFor={field.name}>
                                        Name
                                      </FieldLabel>
                                      <Input
                                        id={field.name}
                                        name={field.name}
                                        placeholder="Ada Lovelace"
                                        aria-invalid={!!rowErrors.name}
                                        value={field.state.value}
                                        onBlur={field.handleBlur}
                                        onChange={(e) =>
                                          field.handleChange(e.target.value)
                                        }
                                      />
                                      {rowErrors.name && (
                                        <FieldError
                                          errors={[{ message: rowErrors.name }]}
                                        />
                                      )}
                                    </Field>
                                  )}
                                </form.Field>
                                <form.Field name={`members[${index}].email`}>
                                  {(field) => (
                                    <Field data-invalid={!!rowErrors.email}>
                                      <FieldLabel htmlFor={field.name}>
                                        Email
                                      </FieldLabel>
                                      <Input
                                        id={field.name}
                                        name={field.name}
                                        type="email"
                                        placeholder="ada@example.com"
                                        aria-invalid={!!rowErrors.email}
                                        value={field.state.value}
                                        onBlur={field.handleBlur}
                                        onChange={(e) =>
                                          field.handleChange(e.target.value)
                                        }
                                      />
                                      {rowErrors.email && (
                                        <FieldError
                                          errors={[
                                            { message: rowErrors.email },
                                          ]}
                                        />
                                      )}
                                    </Field>
                                  )}
                                </form.Field>
                                <form.Field name={`members[${index}].role`}>
                                  {(field) => (
                                    <Field data-invalid={!!rowErrors.role}>
                                      <FieldLabel htmlFor={field.name}>
                                        Role
                                      </FieldLabel>
                                      <Select
                                        value={field.state.value}
                                        onValueChange={(value) =>
                                          field.handleChange(value ?? '')
                                        }
                                      >
                                        <SelectTrigger
                                          id={field.name}
                                          className="w-full"
                                          aria-invalid={!!rowErrors.role}
                                        >
                                          <SelectValue placeholder="Select a role" />
                                        </SelectTrigger>
                                        <SelectContent>
                                          <SelectGroup>
                                            <SelectLabel>
                                              Workspace role
                                            </SelectLabel>
                                            {ROLES.map((role) => (
                                              <SelectItem
                                                key={role.value}
                                                value={role.value}
                                              >
                                                {role.label}
                                              </SelectItem>
                                            ))}
                                          </SelectGroup>
                                        </SelectContent>
                                      </Select>
                                      {rowErrors.role && (
                                        <FieldError
                                          errors={[{ message: rowErrors.role }]}
                                        />
                                      )}
                                    </Field>
                                  )}
                                </form.Field>
                              </FieldGroup>
                              <div className="flex items-center justify-between">
                                <Button
                                  type="button"
                                  variant="destructive"
                                  size="sm"
                                  onClick={() => deleteRow(index)}
                                >
                                  <Trash2Icon className="size-4" />
                                  Remove
                                </Button>
                                <Button
                                  type="button"
                                  size="sm"
                                  onClick={() => commit(index)}
                                >
                                  <CheckIcon className="size-4" />
                                  Done
                                </Button>
                              </div>
                            </div>
                          )
                        }

                        return (
                          <div
                            key={index}
                            className="flex items-center gap-3 rounded-lg border p-3"
                          >
                            <div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">
                              {initials(member.name)}
                            </div>
                            <div className="min-w-0 flex-1">
                              <div className="flex items-center gap-2">
                                <span className="truncate text-sm font-medium">
                                  {member.name}
                                </span>
                                <Badge variant="secondary">
                                  {roleLabel(member.role)}
                                </Badge>
                              </div>
                              <p className="text-muted-foreground truncate text-sm">
                                {member.email}
                              </p>
                            </div>
                            <div className="flex items-center gap-1">
                              <Button
                                type="button"
                                variant="ghost"
                                size="icon-sm"
                                aria-label="Edit teammate"
                                title="Edit teammate"
                                onClick={() => {
                                  setRowErrors({})
                                  setEditingIndex(index)
                                }}
                              >
                                <PencilIcon className="size-4" />
                              </Button>
                              <Button
                                type="button"
                                variant="ghost"
                                size="icon-sm"
                                aria-label="Duplicate teammate"
                                title="Duplicate teammate"
                                onClick={() => duplicate(index)}
                              >
                                <CopyIcon className="size-4" />
                              </Button>
                              <Button
                                type="button"
                                variant="ghost"
                                size="icon-sm"
                                aria-label="Delete teammate"
                                title="Delete teammate"
                                onClick={() => deleteRow(index)}
                              >
                                <Trash2Icon className="size-4" />
                              </Button>
                            </div>
                          </div>
                        )
                      })}
                      <Button
                        type="button"
                        variant="outline"
                        size="sm"
                        className="w-full"
                        onClick={startAdd}
                      >
                        <UserPlusIcon className="size-4" />
                        Add another teammate
                      </Button>
                    </>
                  )}
                </CardContent>
                <CardFooter className="justify-end">
                  <Button
                    type="submit"
                    size="sm"
                    disabled={members.length === 0 || editingIndex !== null}
                  >
                    Send invites
                  </Button>
                </CardFooter>
              </>
            )
          }}
        </form.Field>
      </form>
    </Card>
  )
}
```
