TanStack Form v1でフォーム実装がだいぶ楽になる。型安全と非同期バリデーションを1つにまとめる方法

はじめに

フォーム実装は、入力管理、バリデーション、送信中の状態、エラー表示まで含めると、思ったより細かい仕事が増えます。しかも画面が増えるほど、「この入力だけ別実装」「この非同期チェックだけ独自処理」といったズレが積み重なりやすいところです。

TanStack Formは、そのあたりをヘッドレスに整理できるライブラリです。2025年3月3日にv1が公開され、React、Vue、Angular、Solid、Lit向けの安定版として案内されています。この記事ではReact版を例に、まずは基本の使い方を押さえつつ、実務で効きやすい非同期バリデーションや描画の分け方まで見ていきます。

機能やライブラリの概要

TanStack Formは、フォームの見た目を持たない headless なフォームライブラリです。UIコンポーネントを固定しないので、既存のデザインシステムや自作コンポーネントにそのまま組み込みやすいのが特徴です。

特に実務でうれしいのは、次のあたりです。

  • defaultValuesから型が自然に推論される
  • form.Fieldごとに入力管理とエラー管理を寄せやすい
  • onBlurAsyncなどの非同期バリデーションを素直に書ける
  • form.SubscribeuseStoreで、必要な場所だけ再描画しやすい

React Hook Formのように薄く使うというより、「フォーム状態を型付きで整理したい」「複雑な入力UIを自前で組みたい」ときに相性がいい印象です。

基本の使い方

最初は、プロフィール編集フォームくらいの小さな例が分かりやすいです。このコードでは、入力値の管理、必須チェック、送信ボタンの状態表示をTanStack Formにまとめています。

JSX
import { useForm } from '@tanstack/react-form'

type ProfileFormValues = {
  displayName: string
  bio: string
}

export function ProfileForm() {
  const form = useForm({
    defaultValues: {
      displayName: '',
      bio: '',
    } satisfies ProfileFormValues,
    onSubmit: async ({ value }) => {
      await fetch('/api/profile', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(value),
      })
    },
  })

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        event.stopPropagation()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="displayName"
        validators={{
          onChange: ({ value }) =>
            value.trim().length < 2 ? '2文字以上で入力してください' : undefined,
        }}
      >
        {(field) => (
          <label>
            表示名
            <input
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(event) => field.handleChange(event.target.value)}
            />
            {field.state.meta.errors[0] ? (
              <p>{field.state.meta.errors[0]}</p>
            ) : null}
          </label>
        )}
      </form.Field>

      <form.Field name="bio">
        {(field) => (
          <label>
            自己紹介
            <textarea
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(event) => field.handleChange(event.target.value)}
            />
          </label>
        )}
      </form.Field>

      <form.Subscribe
        selector={(state) => [state.canSubmit, state.isSubmitting]}
      >
        {([canSubmit, isSubmitting]) => (
          <button type="submit" disabled={!canSubmit}>
            {isSubmitting ? '保存中...' : '保存する'}
          </button>
        )}
      </form.Subscribe>
    </form>
  )
}

見てのとおり、フォーム全体の状態はuseFormに、各入力の責務はform.Fieldに寄せています。送信ボタンだけform.Subscribeで購読しているので、フォーム全体をまとめて描き直すより意図が見えやすいのもポイントです。

便利な使いどころ

TanStack Formが効きやすいのは、「入力が多い画面」よりも、むしろ「入力ごとに条件や状態が違う画面」です。たとえば、次のようなフォームです。

  • ユーザー名の重複チェックがある会員登録
  • 商品バリエーションを配列で増減する管理画面
  • 入力値に応じて項目の表示を切り替える申込フォーム
  • デザインシステム上の独自入力コンポーネントを使う案件

特に非同期バリデーションを素直に書けるのは扱いやすいところです。公式ブログでも、AbortSignalベースのキャンセルとデバウンス付きの非同期バリデーションが案内されています。ユーザー名の空きチェックのような「よくあるけれど地味に面倒」な処理を、外側で独自管理しなくて済むのはかなり助かります。

応用コード

ここでは、会員登録フォームで「ユーザー名の重複チェックをぼかし時に実行する」例に広げます。このコードでは、同期チェックとAPIによる非同期チェックを同じ流れで扱っています。

JSX
import { useForm } from '@tanstack/react-form'

async function checkUsernameAvailability(
  username: string,
  signal: AbortSignal,
) {
  const response = await fetch(`/api/users/check-name?value=${username}`, {
    signal,
  })

  if (!response.ok) {
    throw new Error('Failed to validate username')
  }

  const data = (await response.json()) as { available: boolean }

  return data.available
}

export function SignUpForm() {
  const form = useForm({
    defaultValues: {
      username: '',
      password: '',
      newsletter: true,
    },
    onSubmit: async ({ value }) => {
      await fetch('/api/signup', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(value),
      })
    },
  })

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        event.stopPropagation()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="username"
        validators={{
          onChange: ({ value }) =>
            value.length < 3 ? '3文字以上で入力してください' : undefined,
          onBlurAsyncDebounceMs: 400,
          onBlurAsync: async ({ value, signal }) => {
            if (value.length < 3) {
              return undefined
            }

            const available = await checkUsernameAvailability(value, signal)

            return available ? undefined : 'このユーザー名はすでに使われています'
          },
        }}
      >
        {(field) => (
          <label>
            ユーザー名
            <input
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(event) => field.handleChange(event.target.value)}
            />
            {field.state.meta.isValidating ? (
              <p>確認中...</p>
            ) : field.state.meta.errors[0] ? (
              <p>{field.state.meta.errors[0]}</p>
            ) : (
              <p>利用できます</p>
            )}
          </label>
        )}
      </form.Field>

      <form.Field
        name="password"
        validators={{
          onChange: ({ value }) =>
            value.length < 8 ? '8文字以上で入力してください' : undefined,
        }}
      >
        {(field) => (
          <label>
            パスワード
            <input
              type="password"
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(event) => field.handleChange(event.target.value)}
            />
            {field.state.meta.errors[0] ? (
              <p>{field.state.meta.errors[0]}</p>
            ) : null}
          </label>
        )}
      </form.Field>

      <form.Subscribe
        selector={(state) => [state.canSubmit, state.isSubmitting]}
      >
        {([canSubmit, isSubmitting]) => (
          <button type="submit" disabled={!canSubmit}>
            {isSubmitting ? '登録中...' : '登録する'}
          </button>
        )}
      </form.Subscribe>
    </form>
  )
}

この形だと、入力ごとのロジックをフィールドの近くに置けます。フォーム全体の外側でuseStateを増やして管理するより、後から読んだときに「どの入力が何をしているか」が追いやすくなります。

また、将来的にZodやValibotなどのStandard Schema対応ライブラリへ寄せる道もあります。まずは関数ベースで始め、共通化したくなったところからスキーマ化する流れでも十分実用的です。

注意点

  • form.SubscribeuseStoreの使い分けは意識したいです。公式ガイドでも、UIだけを更新したい場面はform.Subscribe、コンポーネント内ロジックで値を使いたい場面はuseStoreが向いていると案内されています。
  • フィールドごとに何でも書けるぶん、入力コンポーネントの共通化方針は先に軽く決めておくと楽です。TextFieldCheckboxFieldのような薄いラッパーを作るだけでも読みやすさがかなり変わります。
  • 非同期バリデーションは便利ですが、毎回サーバーへ飛ばすと体感が悪くなります。onBlurAsyncやデバウンスを使い、onChangeでは軽い同期チェックに留める設計が扱いやすいです。
  • 既存案件へ入れるときは、「すべてのフォームを置き換える」より、新規画面や複雑な画面から試すほうが現実的です。headlessなぶん自由度が高いので、いきなり全置換すると設計差分も増えます。

まとめ

TanStack Form v1は、フォームを豪華に見せるライブラリというより、複雑なフォーム実装を破綻しにくくするための土台です。型推論、非同期バリデーション、購読ベースの描画制御が揃っているので、入力ごとの責務を整理しやすくなります。

特に、会員登録や管理画面の設定フォームのように「入力の種類もルールも多い画面」では効果が見えやすいはずです。まずは1画面だけでも、独自useStateだらけのフォームを置き換えてみると違いが分かりやすいと思います。

ポイント

  • 型安全なフォーム状態管理と非同期バリデーションを1つに寄せやすい
  • form.Fieldで入力ごとの責務を近くに置けるので保守しやすい
  • form.Subscribeを使うと、送信ボタンや補助UIだけを素直に更新しやすい

参考リンク

read next