tanstack-form について
tanstack-form は、型安全なフォームを開発するためのヘッドレスフォームライブラリです。フィールド単位・フォーム単位の同期および非同期バリデーション、依存フィールド、配列フィールド、スキーマアダプターに対応します。
主な機能
- 01フィールド単位とフォーム単位の検証
- 02同期および非同期バリデーター
- 03依存フィールドと配列フィールド
- 04Zod と Valibot のアダプター
- 05細粒度のリアクティブな購読
活用シーン
- 01編集時や送信時のフィールド検証
- 02依存するフィールド間の値の確認
- 03繰り返し項目のあるフォーム管理
- 04スキーマベースの検証適用
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ tanstack-skills/ tanstack-skills --skill tanstack-formインストール後、Claude Code で/tanstack-formを入力して読み込まれたか確認します。
詳細
概要
TanStack Form は、TypeScript と深く統合されたヘッドレスフォームライブラリです。フィールド単位およびフォーム単位の検証(同期/非同期)、配列フィールド、連動フィールド/依存フィールド、きめ細かなリアクティビティ、スキーマ検証アダプターのサポート(Zod、Valibot、Yup)を提供します。
パッケージ: @tanstack/react-form
アダプター: @tanstack/zod-form-adapter、@tanstack/valibot-form-adapter
ステータス: 安定版(v1)
インストール
npm install @tanstack/react-form
# Optional schema adapters:
npm install @tanstack/zod-form-adapter zod
npm install @tanstack/valibot-form-adapter valibot
コア:useForm
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>
)
}
フィールド(form.Field)
<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>
)}
/>
<form.Field name="address.city">
{(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
)}
</form.Field>
検証
検証のタイミング
| 原因 | タイミング |
|---|---|
onChange | 値が変更されるたび |
onBlur | フィールドのフォーカスが外れたとき |
onSubmit | 送信時 |
onMount | フィールドのマウント時 |
同期検証
<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
},
}}
/>
非同期検証
<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>
スキーマ検証(Zod)
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+'),
}}
/>
フォーム単位の検証
const form = useForm({
defaultValues: { password: '', confirmPassword: '' },
validators: {
onChange: ({ value }) => {
if (value.password !== value.confirmPassword) {
return 'Passwords do not match'
}
return undefined
},
},
})
連動フィールド/依存フィールド
<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
},
}}
/>
配列フィールド
<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>
配列メソッド
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
リスナー(副作用)
<form.Field
name="country"
listeners={{
onChange: ({ value }) => {
// Side effect: reset dependent fields
form.setFieldValue('state', '')
form.setFieldValue('postalCode', '')
},
}}
/>
リアクティビティ(form.Subscribe と useStore)
// 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>
}
フォームの状態
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
}
フィールドの状態
interface FieldState<TData> {
value: TData
meta: {
isTouched: boolean
isDirty: boolean
isPristine: boolean
isValidating: boolean
errors: ValidationError[]
errorMap: Record<ValidationCause, ValidationError>
}
}
FormApi のメソッド
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)
共通のフォームオプション(formOptions)
import { formOptions } from '@tanstack/react-form'
const sharedOpts = formOptions({
defaultValues: { firstName: '', lastName: '' },
})
// Reuse across components
const form = useForm({
...sharedOpts,
onSubmit: async ({ value }) => { /* ... */ },
})
サーバーサイド検証
// 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 との統合
// 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!
ベストプラクティス
- フォームの送信時には、必ず
e.preventDefault()とe.stopPropagation()を呼び出します - ぼかし時の検証と isTouched の追跡のため、必ず
onBlur={field.handleBlur}を設定します - 配列メソッドを使用するには、配列フィールドで
mode="array"を使用します - 有効なバリデーターでは、
undefined(null/falseではなく)を返します - 非同期バリデーターによる API の過剰な呼び出しを防ぐには、
asyncDebounceMsを使用します - UX を向上させるため、エラーを表示する前に
isTouchedを確認します - 再レンダリングを最小限に抑えるには、セレクターを使って
form.Subscribeを使用します - コンポーネント間で設定を共有するには、
formOptionsを使用します - 複雑な検証ルールには、スキーマバリデーター(Zod/Valibot)を 使用します
- フィールド間の検証依存関係には、
onChangeListenToを使用します
よくある落とし穴
- フォームの送信時に
e.preventDefault()を呼び忘れる(ページが再読み込みされます) - 入力に
onBlurを設定しない(ぼかし時の検証と isTouched が機能しません) - 有効なフィールドに対して、
undefinedではなくnullまたはfalseを返す mode="array"を誤って使用する(必要なのは配列フィールド自体であり、サブフィールドではありません)- セレクターを使わずフォームの状態全体を購読する(不要な再レンダリングが発生します)
- 非同期バリデーターで
asyncDebounceMsを使用しない(キー入力のたびに実行されます)
コメント
Markdown 対応