mcpField
T
tanstack-form

tanstack-form

github.com/tanstack-skills開発364.4k 回インストール39 週間前に更新
リポジトリ

tanstack-form について

tanstack-form は、型安全なフォームを開発するためのヘッドレスフォームライブラリです。フィールド単位・フォーム単位の同期および非同期バリデーション、依存フィールド、配列フィールド、スキーマアダプターに対応します。

主な機能

  1. 01フィールド単位とフォーム単位の検証
  2. 02同期および非同期バリデーター
  3. 03依存フィールドと配列フィールド
  4. 04Zod と Valibot のアダプター
  5. 05細粒度のリアクティブな購読

活用シーン

  1. 01編集時や送信時のフィールド検証
  2. 02依存するフィールド間の値の確認
  3. 03繰り返し項目のあるフォーム管理
  4. 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!

ベストプラクティス

  1. フォームの送信時には、必ず e.preventDefault() と e.stopPropagation() を呼び出します
  2. ぼかし時の検証と isTouched の追跡のため、必ず onBlur={field.handleBlur} を設定します
  3. 配列メソッドを使用するには、配列フィールドで mode="array" を使用します
  4. 有効なバリデーターでは、undefined(null/false ではなく)を返します
  5. 非同期バリデーターによる API の過剰な呼び出しを防ぐには、asyncDebounceMs を使用します
  6. UX を向上させるため、エラーを表示する前に isTouched を確認します
  7. 再レンダリングを最小限に抑えるには、セレクターを使って form.Subscribe を使用します
  8. コンポーネント間で設定を共有するには、formOptions を使用します
  9. 複雑な検証ルールには、スキーマバリデーター(Zod/Valibot)を 使用します
  10. フィールド間の検証依存関係には、onChangeListenTo を使用します

よくある落とし穴

  • フォームの送信時に e.preventDefault() を呼び忘れる(ページが再読み込みされます)
  • 入力に onBlur を設定しない(ぼかし時の検証と isTouched が機能しません)
  • 有効なフィールドに対して、undefined ではなく null または false を返す
  • mode="array" を誤って使用する(必要なのは配列フィールド自体であり、サブフィールドではありません)
  • セレクターを使わずフォームの状態全体を購読する(不要な再レンダリングが発生します)
  • 非同期バリデーターで asyncDebounceMs を使用しない(キー入力のたびに実行されます)

コメント