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-domA 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
findByTextfor async) - Async validation → mock fetch, wait past debounce delay, assert error or no-error
- Form submission → fill valid data, click submit, assert
onSubmitcalled 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
onSubmitrejection, 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.