mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-03 19:37:52 +00:00
416 lines
9.9 KiB
Markdown
416 lines
9.9 KiB
Markdown
---
|
|||
|
|
name: tanstack-form
|
||
|
|
description: Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte.
|
||
|
|
---
|
||
|
|
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
TanStack Form is a headless form library with deep TypeScript integration. It provides field-level and form-level validation (sync/async), array fields, linked/dependent fields, fine-grained reactivity, and schema validation adapter support (Zod, Valibot, Yup).
|
||
|
|
|
||
|
|
**Package:** `@tanstack/react-form`
|
||
|
|
**Adapters:** `@tanstack/zod-form-adapter`, `@tanstack/valibot-form-adapter`
|
||
|
|
**Status:** Stable (v1)
|
||
|
|
|
||
|
|
## Installation
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm install @tanstack/react-form
|
||
|
|
# Optional schema adapters:
|
||
|
|
npm install @tanstack/zod-form-adapter zod
|
||
|
|
npm install @tanstack/valibot-form-adapter valibot
|
||
|
|
```
|
||
|
|
|
||
|
|
## Core: useForm
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { useForm } from '@tanstack/react-form'
|
||
|
|
|
||
|
|
function MyForm() {
|
||
|
|
const form = useForm({
|
||
|
|
defaultValues: {
|
||
|
|
firstName: '',
|
||
|
|
lastName: '',
|
||
|
|
email: '',
|
||
|
|
age: 0,
|
||
|
|
},
|
||
|
|
onSubmit: async ({ value }) => {
|
||
|
|
// value is fully typed
|
||
|
|
await submitToServer(value)
|
||
|
|
},
|
||
|
|
onSubmitInvalid: ({ value, formApi }) => {
|
||
|
|
console.log('Validation failed:', formApi.state.errors)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
return (
|
||
|
|
<form
|
||
|
|
onSubmit={(e) => {
|
||
|
|
e.preventDefault()
|
||
|
|
e.stopPropagation()
|
||
|
|
form.handleSubmit()
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* Fields */}
|
||
|
|
<form.Subscribe
|
||
|
|
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
||
|
|
children={({ canSubmit, isSubmitting }) => (
|
||
|
|
<button type="submit" disabled={!canSubmit}>
|
||
|
|
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</form>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Fields (form.Field)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<form.Field
|
||
|
|
name="firstName"
|
||
|
|
validators={{
|
||
|
|
onChange: ({ value }) =>
|
||
|
|
value.length < 3 ? 'Must be at least 3 characters' : undefined,
|
||
|
|
}}
|
||
|
|
children={(field) => (
|
||
|
|
<div>
|
||
|
|
<label htmlFor={field.name}>First Name</label>
|
||
|
|
<input
|
||
|
|
id={field.name}
|
||
|
|
name={field.name}
|
||
|
|
value={field.state.value}
|
||
|
|
onBlur={field.handleBlur}
|
||
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
||
|
|
/>
|
||
|
|
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
|
||
|
|
<em>{field.state.meta.errors.join(', ')}</em>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<!-- Nested fields use dot notation -->
|
||
|
|
<form.Field name="address.city">
|
||
|
|
{(field) => (
|
||
|
|
<input
|
||
|
|
value={field.state.value}
|
||
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
||
|
|
onBlur={field.handleBlur}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</form.Field>
|
||
|
|
```
|
||
|
|
|
||
|
|
## Validation
|
||
|
|
|
||
|
|
### Validation Timing
|
||
|
|
|
||
|
|
| Cause | When |
|
||
|
|
|-------|------|
|
||
|
|
| `onChange` | After every value change |
|
||
|
|
| `onBlur` | When field loses focus |
|
||
|
|
| `onSubmit` | During submission |
|
||
|
|
| `onMount` | When field mounts |
|
||
|
|
|
||
|
|
### Synchronous Validation
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<form.Field
|
||
|
|
name="age"
|
||
|
|
validators={{
|
||
|
|
onChange: ({ value }) => {
|
||
|
|
if (value < 18) return 'Must be 18 or older'
|
||
|
|
return undefined // undefined = valid
|
||
|
|
},
|
||
|
|
onBlur: ({ value }) => {
|
||
|
|
if (!value) return 'Required'
|
||
|
|
return undefined
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Asynchronous Validation
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<form.Field
|
||
|
|
name="username"
|
||
|
|
asyncDebounceMs={500}
|
||
|
|
validators={{
|
||
|
|
onChangeAsync: async ({ value }) => {
|
||
|
|
const res = await fetch(`/api/check-username?q=${value}`)
|
||
|
|
const { available } = await res.json()
|
||
|
|
if (!available) return 'Username taken'
|
||
|
|
return undefined
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{(field) => (
|
||
|
|
<>
|
||
|
|
<input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
|
||
|
|
{field.state.meta.isValidating && <span>Checking...</span>}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</form.Field>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Schema Validation (Zod)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { zodValidator } from '@tanstack/zod-form-adapter'
|
||
|
|
import { z } from 'zod'
|
||
|
|
|
||
|
|
const form = useForm({
|
||
|
|
defaultValues: { email: '', age: 0 },
|
||
|
|
validatorAdapter: zodValidator(),
|
||
|
|
onSubmit: async ({ value }) => { /* ... */ },
|
||
|
|
})
|
||
|
|
|
||
|
|
<form.Field
|
||
|
|
name="email"
|
||
|
|
validators={{
|
||
|
|
onChange: z.string().email('Invalid email'),
|
||
|
|
onBlur: z.string().min(1, 'Required'),
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<form.Field
|
||
|
|
name="age"
|
||
|
|
validators={{
|
||
|
|
onChange: z.number().min(18, 'Must be 18+'),
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Form-Level Validation
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
const form = useForm({
|
||
|
|
defaultValues: { password: '', confirmPassword: '' },
|
||
|
|
validators: {
|
||
|
|
onChange: ({ value }) => {
|
||
|
|
if (value.password !== value.confirmPassword) {
|
||
|
|
return 'Passwords do not match'
|
||
|
|
}
|
||
|
|
return undefined
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### Linked/Dependent Fields
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<form.Field
|
||
|
|
name="confirmPassword"
|
||
|
|
validators={{
|
||
|
|
onChangeListenTo: ['password'], // Re-validate when password changes
|
||
|
|
onChange: ({ value, fieldApi }) => {
|
||
|
|
const password = fieldApi.form.getFieldValue('password')
|
||
|
|
if (value !== password) return 'Passwords do not match'
|
||
|
|
return undefined
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
## Array Fields
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<form.Field name="people" mode="array">
|
||
|
|
{(field) => (
|
||
|
|
<div>
|
||
|
|
{field.state.value.map((_, index) => (
|
||
|
|
<div key={index}>
|
||
|
|
<form.Field name={`people[${index}].name`}>
|
||
|
|
{(subField) => (
|
||
|
|
<input
|
||
|
|
value={subField.state.value}
|
||
|
|
onChange={(e) => subField.handleChange(e.target.value)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</form.Field>
|
||
|
|
<button type="button" onClick={() => field.removeValue(index)}>
|
||
|
|
Remove
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
<button type="button" onClick={() => field.pushValue({ name: '', age: 0 })}>
|
||
|
|
Add Person
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</form.Field>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Array Methods
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
field.pushValue(item) // Add to end
|
||
|
|
field.insertValue(index, item) // Insert at index
|
||
|
|
field.replaceValue(index, item) // Replace at index
|
||
|
|
field.removeValue(index) // Remove at index
|
||
|
|
field.swapValues(indexA, indexB) // Swap positions
|
||
|
|
field.moveValue(from, to) // Move position
|
||
|
|
```
|
||
|
|
|
||
|
|
## Listeners (Side Effects)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<form.Field
|
||
|
|
name="country"
|
||
|
|
listeners={{
|
||
|
|
onChange: ({ value }) => {
|
||
|
|
// Side effect: reset dependent fields
|
||
|
|
form.setFieldValue('state', '')
|
||
|
|
form.setFieldValue('postalCode', '')
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
## Reactivity (form.Subscribe & useStore)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
// Render-prop subscription (fine-grained)
|
||
|
|
<form.Subscribe
|
||
|
|
selector={(state) => ({ canSubmit: state.canSubmit, isDirty: state.isDirty })}
|
||
|
|
children={({ canSubmit, isDirty }) => (
|
||
|
|
<div>
|
||
|
|
{isDirty && <span>Unsaved changes</span>}
|
||
|
|
<button disabled={!canSubmit}>Save</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
// Hook-based subscription
|
||
|
|
function FormStatus() {
|
||
|
|
const isValid = form.useStore((s) => s.isValid)
|
||
|
|
return isValid ? null : <p>Fix errors</p>
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Form State
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface FormState {
|
||
|
|
values: TFormData
|
||
|
|
errors: ValidationError[]
|
||
|
|
errorMap: Record<string, ValidationError>
|
||
|
|
isFormValid: boolean
|
||
|
|
isFieldsValid: boolean
|
||
|
|
isValid: boolean // isFormValid && isFieldsValid
|
||
|
|
isTouched: boolean
|
||
|
|
isPristine: boolean
|
||
|
|
isDirty: boolean
|
||
|
|
isSubmitting: boolean
|
||
|
|
isSubmitted: boolean
|
||
|
|
isSubmitSuccessful: boolean
|
||
|
|
submissionAttempts: number
|
||
|
|
canSubmit: boolean // isValid && !isSubmitting
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Field State
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface FieldState<TData> {
|
||
|
|
value: TData
|
||
|
|
meta: {
|
||
|
|
isTouched: boolean
|
||
|
|
isDirty: boolean
|
||
|
|
isPristine: boolean
|
||
|
|
isValidating: boolean
|
||
|
|
errors: ValidationError[]
|
||
|
|
errorMap: Record<ValidationCause, ValidationError>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## FormApi Methods
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
form.handleSubmit()
|
||
|
|
form.reset()
|
||
|
|
form.getFieldValue(field)
|
||
|
|
form.setFieldValue(field, value)
|
||
|
|
form.getFieldMeta(field)
|
||
|
|
form.setFieldMeta(field, updater)
|
||
|
|
form.validateAllFields(cause)
|
||
|
|
form.validateField(field, cause)
|
||
|
|
form.deleteField(field)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Shared Form Options (formOptions)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { formOptions } from '@tanstack/react-form'
|
||
|
|
|
||
|
|
const sharedOpts = formOptions({
|
||
|
|
defaultValues: { firstName: '', lastName: '' },
|
||
|
|
})
|
||
|
|
|
||
|
|
// Reuse across components
|
||
|
|
const form = useForm({
|
||
|
|
...sharedOpts,
|
||
|
|
onSubmit: async ({ value }) => { /* ... */ },
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
## Server-Side Validation
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
// TanStack Start / Next.js server action
|
||
|
|
import { ServerValidateError } from '@tanstack/react-form/nextjs'
|
||
|
|
|
||
|
|
export async function validateForm(data: FormData) {
|
||
|
|
const email = data.get('email') as string
|
||
|
|
if (await checkEmailExists(email)) {
|
||
|
|
throw new ServerValidateError({
|
||
|
|
form: 'Submission failed',
|
||
|
|
fields: { email: 'Email already registered' },
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## TypeScript Integration
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
// Type-safe field paths with DeepKeys
|
||
|
|
interface UserForm {
|
||
|
|
name: string
|
||
|
|
address: { street: string; city: string }
|
||
|
|
tags: string[]
|
||
|
|
contacts: Array<{ name: string; phone: string }>
|
||
|
|
}
|
||
|
|
|
||
|
|
// TypeScript auto-completes all valid paths:
|
||
|
|
// 'name', 'address', 'address.street', 'address.city', 'tags', 'contacts'
|
||
|
|
<form.Field name="address.city" /> // OK
|
||
|
|
<form.Field name="nonexistent" /> // Type Error!
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
1. **Always call `e.preventDefault()` and `e.stopPropagation()`** on form submit
|
||
|
|
2. **Always attach `onBlur={field.handleBlur}`** for blur validation and isTouched tracking
|
||
|
|
3. **Use `mode="array"`** for array fields to get array methods
|
||
|
|
4. **Return `undefined`** (not null/false) for valid validators
|
||
|
|
5. **Use `asyncDebounceMs`** for async validators to prevent API spam
|
||
|
|
6. **Check `isTouched` before showing errors** for better UX
|
||
|
|
7. **Use `form.Subscribe` with selectors** to minimize re-renders
|
||
|
|
8. **Use `formOptions`** for shared configuration across components
|
||
|
|
9. **Use schema validators** (Zod/Valibot) for complex validation rules
|
||
|
|
10. **Use `onChangeListenTo`** for cross-field validation dependencies
|
||
|
|
|
||
|
|
## Common Pitfalls
|
||
|
|
|
||
|
|
- Forgetting `e.preventDefault()` on form submit (causes page reload)
|
||
|
|
- Not attaching `onBlur` to inputs (breaks blur validation and isTouched)
|
||
|
|
- Returning `null` or `false` instead of `undefined` for valid fields
|
||
|
|
- Using `mode="array"` incorrectly (only needed on the array field itself, not sub-fields)
|
||
|
|
- Subscribing to entire form state instead of using selectors (unnecessary re-renders)
|
||
|
|
- Not using `asyncDebounceMs` with async validators (fires on every keystroke)
|