Testing TanStack Form: Validation, Async Submission, and Field Arrays

Testing TanStack Form: Validation, Async Submission, and Field Arrays

TanStack Form is a headless form library with built-in TypeScript support, field-level validation, async submission, and field arrays. Its headless design means your tests interact with real DOM elements through the form library's state — which makes Testing Library an ideal fit.

This guide covers testing TanStack Form: field validation, async submission flows, error handling, and dynamic field arrays.

Setup

npm install @tanstack/react-form
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

A Basic Form to Test

// components/CreateUserForm.tsx
import { useForm } from '@tanstack/react-form'

interface CreateUserValues {
  name: string
  email: string
  role: 'admin' | 'user' | 'viewer'
}

interface Props {
  onSubmit: (values: CreateUserValues) => Promise<void>
}

export function CreateUserForm({ onSubmit }: Props) {
  const form = useForm<CreateUserValues>({
    defaultValues: {
      name: '',
      email: '',
      role: 'user',
    },
    onSubmit: async ({ value }) => {
      await onSubmit(value)
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="name"
        validators={{
          onChange: ({ value }) =>
            !value ? 'Name is required' : value.length < 2 ? 'Name too short' : undefined,
        }}
      >
        {(field) => (
          <div>
            <label htmlFor={field.name}>Name</label>
            <input
              id={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              aria-describedby={field.state.meta.errors.length ? `${field.name}-error` : undefined}
            />
            {field.state.meta.errors.length > 0 && (
              <p id={`${field.name}-error`} role="alert">
                {field.state.meta.errors[0]}
              </p>
            )}
          </div>
        )}
      </form.Field>

      <form.Field
        name="email"
        validators={{
          onChange: ({ value }) => {
            if (!value) return 'Email is required'
            if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email'
            return undefined
          },
        }}
      >
        {(field) => (
          <div>
            <label htmlFor={field.name}>Email</label>
            <input
              id={field.name}
              type="email"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors.length > 0 && (
              <p role="alert">{field.state.meta.errors[0]}</p>
            )}
          </div>
        )}
      </form.Field>

      <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
        {([canSubmit, isSubmitting]) => (
          <button type="submit" disabled={!canSubmit || isSubmitting}>
            {isSubmitting ? 'Creating…' : 'Create user'}
          </button>
        )}
      </form.Subscribe>
    </form>
  )
}

Testing Field Validation

TanStack Form validates onChange and onBlur. Testing validation requires triggering the right event:

// components/CreateUserForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { CreateUserForm } from './CreateUserForm'

describe('CreateUserForm — validation', () => {
  const noop = vi.fn().mockResolvedValue(undefined)

  it('shows name required error when field is left empty', async () => {
    render(<CreateUserForm onSubmit={noop} />)

    // Type something then clear it to trigger onChange validation
    const nameInput = screen.getByLabelText('Name')
    await userEvent.type(nameInput, 'A')
    await userEvent.clear(nameInput)

    expect(await screen.findByText('Name is required')).toBeInTheDocument()
  })

  it('shows name too short error for single character', async () => {
    render(<CreateUserForm onSubmit={noop} />)

    await userEvent.type(screen.getByLabelText('Name'), 'A')

    expect(await screen.findByText('Name too short')).toBeInTheDocument()
  })

  it('shows email format error for invalid email', async () => {
    render(<CreateUserForm onSubmit={noop} />)

    await userEvent.type(screen.getByLabelText('Email'), 'not-an-email')

    expect(await screen.findByText('Invalid email')).toBeInTheDocument()
  })

  it('clears error when valid input is entered', async () => {
    render(<CreateUserForm onSubmit={noop} />)

    const nameInput = screen.getByLabelText('Name')
    await userEvent.type(nameInput, 'A')
    expect(await screen.findByText('Name too short')).toBeInTheDocument()

    await userEvent.type(nameInput, 'lice')
    expect(screen.queryByText('Name too short')).not.toBeInTheDocument()
  })

  it('submit button is disabled when form has validation errors', async () => {
    render(<CreateUserForm onSubmit={noop} />)

    // Trigger validation by typing invalid values
    await userEvent.type(screen.getByLabelText('Name'), 'A')

    const submitButton = screen.getByRole('button', { name: 'Create user' })
    expect(submitButton).toBeDisabled()
  })
})

Testing Async Validation

TanStack Form supports async validators for server-side checks (like unique email):

// Field with async validation
<form.Field
  name="email"
  validators={{
    onChange: ({ value }) => {
      if (!value) return 'Email is required'
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email'
    },
    onChangeAsync: async ({ value }) => {
      const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`)
      const { taken } = await res.json()
      if (taken) return 'Email already in use'
    },
    onChangeAsyncDebounceMs: 500,
  }}
>
describe('CreateUserForm — async email validation', () => {
  it('shows taken email error after debounce', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ taken: true }),
    } as Response)

    render(<CreateUserForm onSubmit={vi.fn()} />)

    await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com')

    // Wait for debounce + async validation
    expect(await screen.findByText('Email already in use', {}, { timeout: 2000 })).toBeInTheDocument()
  })

  it('shows no error for available email', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ taken: false }),
    } as Response)

    render(<CreateUserForm onSubmit={vi.fn()} />)

    await userEvent.type(screen.getByLabelText('Email'), 'new@example.com')

    await new Promise((resolve) => setTimeout(resolve, 700)) // past debounce

    expect(screen.queryByText('Email already in use')).not.toBeInTheDocument()
  })
})

Testing Form Submission

Test the full submission flow: fill valid data, submit, verify onSubmit is called with correct values:

describe('CreateUserForm — submission', () => {
  it('calls onSubmit with correct values', async () => {
    const onSubmit = vi.fn().mockResolvedValue(undefined)

    render(<CreateUserForm onSubmit={onSubmit} />)

    await userEvent.type(screen.getByLabelText('Name'), 'Alice Chen')
    await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com')

    await userEvent.click(screen.getByRole('button', { name: 'Create user' }))

    expect(onSubmit).toHaveBeenCalledWith({
      name: 'Alice Chen',
      email: 'alice@example.com',
      role: 'user',
    })
  })

  it('shows submitting state while onSubmit is in progress', async () => {
    let resolve!: () => void
    const onSubmit = vi.fn(
      () => new Promise<void>((r) => { resolve = r })
    )

    render(<CreateUserForm onSubmit={onSubmit} />)

    await userEvent.type(screen.getByLabelText('Name'), 'Alice Chen')
    await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com')

    await userEvent.click(screen.getByRole('button', { name: 'Create user' }))

    // During submission
    expect(screen.getByRole('button', { name: 'Creating…' })).toBeDisabled()

    await act(async () => { resolve() })

    // After completion
    expect(screen.getByRole('button', { name: 'Create user' })).not.toBeDisabled()
  })

  it('does not submit when required fields are empty', async () => {
    const onSubmit = vi.fn().mockResolvedValue(undefined)

    render(<CreateUserForm onSubmit={onSubmit} />)

    await userEvent.click(screen.getByRole('button', { name: 'Create user' }))

    expect(onSubmit).not.toHaveBeenCalled()
  })
})

Testing Field Arrays

TanStack Form has built-in support for dynamic field arrays (e.g., a list of phone numbers or addresses):

// components/ContactForm.tsx
import { useForm } from '@tanstack/react-form'

interface ContactValues {
  name: string
  phones: string[]
}

export function ContactForm({ onSubmit }: { onSubmit: (v: ContactValues) => void }) {
  const form = useForm<ContactValues>({
    defaultValues: { name: '', phones: [''] },
    onSubmit: ({ value }) => onSubmit(value),
  })

  return (
    <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
      <form.Field name="name">
        {(field) => (
          <>
            <label htmlFor={field.name}>Name</label>
            <input
              id={field.name}
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
          </>
        )}
      </form.Field>

      <form.Field name="phones" mode="array">
        {(phonesField) => (
          <div>
            <label>Phone numbers</label>
            {phonesField.state.value.map((_, idx) => (
              <div key={idx}>
                <form.Field name={`phones[${idx}]`}>
                  {(field) => (
                    <input
                      aria-label={`Phone ${idx + 1}`}
                      value={field.state.value}
                      onChange={(e) => field.handleChange(e.target.value)}
                    />
                  )}
                </form.Field>
                <button
                  type="button"
                  onClick={() => phonesField.removeValue(idx)}
                >
                  Remove
                </button>
              </div>
            ))}
            <button
              type="button"
              onClick={() => phonesField.pushValue('')}
            >
              Add phone
            </button>
          </div>
        )}
      </form.Field>

      <button type="submit">Save contact</button>
    </form>
  )
}
// components/ContactForm.test.tsx
describe('ContactForm — field arrays', () => {
  it('starts with one phone field', () => {
    render(<ContactForm onSubmit={vi.fn()} />)

    expect(screen.getAllByRole('textbox', { name: /Phone/ })).toHaveLength(1)
  })

  it('adds a new phone field when Add phone is clicked', async () => {
    render(<ContactForm onSubmit={vi.fn()} />)

    await userEvent.click(screen.getByRole('button', { name: 'Add phone' }))

    expect(screen.getAllByRole('textbox', { name: /Phone/ })).toHaveLength(2)
  })

  it('removes a phone field when Remove is clicked', async () => {
    render(<ContactForm onSubmit={vi.fn()} />)

    // Add two phones first
    await userEvent.click(screen.getByRole('button', { name: 'Add phone' }))
    expect(screen.getAllByRole('textbox', { name: /Phone/ })).toHaveLength(2)

    // Remove the first
    const removeButtons = screen.getAllByRole('button', { name: 'Remove' })
    await userEvent.click(removeButtons[0])

    expect(screen.getAllByRole('textbox', { name: /Phone/ })).toHaveLength(1)
  })

  it('submits all phone values correctly', async () => {
    const onSubmit = vi.fn()

    render(<ContactForm onSubmit={onSubmit} />)

    await userEvent.type(screen.getByLabelText('Name'), 'Alice Chen')
    await userEvent.type(screen.getByRole('textbox', { name: 'Phone 1' }), '555-0001')

    await userEvent.click(screen.getByRole('button', { name: 'Add phone' }))
    await userEvent.type(screen.getByRole('textbox', { name: 'Phone 2' }), '555-0002')

    await userEvent.click(screen.getByRole('button', { name: 'Save contact' }))

    expect(onSubmit).toHaveBeenCalledWith({
      name: 'Alice Chen',
      phones: ['555-0001', '555-0002'],
    })
  })
})

Testing Server-Side Error Handling

When the server rejects a submission, display the error to the user:

describe('CreateUserForm — server errors', () => {
  it('displays server error message on submission failure', async () => {
    const onSubmit = vi.fn().mockRejectedValue(new Error('Email already registered'))

    render(<CreateUserForm onSubmit={onSubmit} />)

    await userEvent.type(screen.getByLabelText('Name'), 'Alice Chen')
    await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com')

    await userEvent.click(screen.getByRole('button', { name: 'Create user' }))

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Email already registered')
    })
  })
})

What Automated Tests Miss

These unit tests cover form logic comprehensively but not:

  • Accessibility — whether screen readers announce validation errors correctly
  • Autofill — browser autofill behaviour with TanStack Form fields
  • Mobile keyboard — whether numeric/email keyboard types trigger correctly on real devices
  • Tab order — whether keyboard navigation through the form works as expected

HelpMeTest runs real browser tests that include keyboard navigation and screen reader compatibility checks — gaps that unit tests can't fill.

Summary

TanStack Form testing with React Testing Library follows the user's path:

  • Field validation → type values, assert error messages appear (using findByText for async)
  • Async validation → mock fetch, wait past debounce delay, assert error or no-error
  • Form submission → fill valid data, click submit, assert onSubmit called with correct values
  • Submitting state → use deferred promise, assert disabled button with loading label
  • Field arrays → assert count of fields, test add/remove actions, verify submitted array values
  • Server errors → mock onSubmit rejection, assert error displayed to user

The headless design of TanStack Form means your tests work entirely through the DOM — exactly what React Testing Library is built for.

Read more