TanStack Formは、TanStack Query・TanStack Router・TanStack Startと同じTanStackが開発しているフォーム状態管理ライブラリです。UIを持たないヘッドレス設計で、フィールド名や値の型をTypeScriptが推論します。2026年11月4日時点の安定版は@tanstack/react-form 1.33.5で、作り直された次期版v2が2.0.0-alpha.3として公開されています。この記事では1.33.5と2.0.0-alpha.3をReact 19・zod 4.6.7・yup 1.7.1と組み合わせて型検査と実行を行い、導入手順、検証の落とし穴、React Hook Formとの選び分け、v2で変わる書き方を整理します。
まとめ:TanStack Formの導入と選定の要点
- パッケージ:Reactなら
npm install @tanstack/react-form。@tanstack/formという名前のパッケージはありません。1系はReact 17・18・19に対応します。 - 版:本番で使うのは1.33.5(2026年8月11日)。v2は2026年10月23日の
2.0.0-alpha.3が最新で、検証の書き方と描画の参照先が変わります。 - スキーマ検証:zod・valibot・ArkTypeはスキーマを
validatorsにそのまま渡せます。yupは同期のonBlurに渡すと例外になるためonBlurAsyncに渡します。 - エラー表示:スキーマ由来のエラーはオブジェクトです。
errors.join(',')ではなくerr?.messageを表示します。 - React Hook Formとの違い:入力は
field.state.valueとhandleChangeで結ぶ制御型で、Vue・Angular・Solid・Svelte・Litでも同じ設定で書けます。React専用で書く量を減らしたいならReact Hook Formが手軽で、useForm単体のgzip実測も約5KB小さい値でした。
TanStack Formの現在の版とパッケージ構成
npmの公開履歴では、初版0.0.1が2023年4月25日、1.0.0が2025年3月2日です。1系はその後小刻みに更新され、2026年8月11日の1.33.5がlatestのまま約3か月続いています。開発の中心はv2へ移っており、alphaタグには2026年8月10日から10月23日までに4つの版が出ました。リポジトリはMITライセンスで、GitHubのスター数は6,762(2026年11月4日)です。
| 用途 | パッケージ | latest | alpha |
|---|---|---|---|
| React | @tanstack/react-form |
1.33.5 | 2.0.0-alpha.3 |
| Vue | @tanstack/vue-form |
1.33.5 | 2.0.0-alpha.3 |
| Angular | @tanstack/angular-form |
1.33.5 | 2.0.0-alpha.3 |
| Solid・Svelte | @tanstack/solid-formほか |
1.33.5 | 2.0.0-alpha.3 |
| Lit | @tanstack/lit-form |
1.25.5 | 2.0.0-alpha.3 |
| Next.js連携 | @tanstack/react-form-nextjs |
1.33.5 | 2.0.0-alpha.3 |
| Devtools | @tanstack/react-form-devtools |
0.2.34 | 1.0.0-alpha.3 |
フレームワーク非依存のロジックは@tanstack/form-coreにあり、各アダプタはその上に乗っています。サーバーの取得結果を初期値に使う構成ではTanStack Queryと、画面遷移と型をそろえる構成ではTanStack Routerと組み合わせるのが一般的です。
@tanstack/react-formのインストールと最小のフォーム
インストールは1行です。依存として@tanstack/form-coreと@tanstack/react-storeが入り、スキーマ検証に使うライブラリは別に入れます。
npm install @tanstack/[email protected] [email protected]
次のコードは、メールアドレスとパスワードを持つ登録フォームです。tsc 7.0.2のstrictで型エラーなしを確認しています。
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
const signupSchema = z.object({
email: z.string().email('メールアドレスの形式で入力してください'),
password: z.string().min(8, '8文字以上で入力してください'),
})
export function SignupForm() {
const form = useForm({
defaultValues: { email: '', password: '' },
validators: { onChange: signupSchema },
onSubmit: async ({ value }) => {
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value),
})
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Field name="email">
{(field) => (
<div>
<label htmlFor={field.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.map((err) => (
<p key={err?.message} role="alert">{err?.message}</p>
))}
</div>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<div>
<label htmlFor={field.name}>パスワード</label>
<input
id={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched &&
field.state.meta.errors.map((err) => (
<p key={err?.message} role="alert">{err?.message}</p>
))}
</div>
)}
</form.Field>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '送信中…' : '登録する'}
</button>
)}
</form.Subscribe>
</form>
)
}
読み方の要点は4つです。
<form.Field name="email">の子に関数を渡し、field.state.valueとfield.handleChangeを入力要素へ自分で結びます。registerやgetInputPropsのような「propsをまとめて返す関数」はありません。form.handleSubmit()は送信イベントを止めないため、e.preventDefault()を先に呼びます。form.Subscribeはselectorで選んだ値が変わったときだけ再描画します。ボタンの状態はここで読みます。canSubmitは、未操作の間はtrueです。例外はonMountの検証がエラーを返した場合で、実測では未操作でもfalseになりました。
フィールド名はdefaultValuesから推論されます。上の登録フォームでname="emial"と打ち間違えると、tsc 7.0.2は次のエラーで止めます。数値のフィールドにe.target.value(文字列)をそのまま渡した場合もTS2345になります。
<form.Field name="emial">
// error TS2820: Type '"emial"' is not assignable to type '"email" | "password"'.
// Did you mean '"email"'?
バリデーションの書き方:zod・valibot・yupの渡し方と落とし穴
1系の検証は、フィールドのvalidatorsかuseFormのvalidatorsに、実行するタイミングをキーにして書きます。同期はonMount・onChange・onBlur・onSubmit、非同期はonChangeAsync・onBlurAsync・onSubmitAsyncで、onMountに非同期版はありません。関数を渡してエラー文字列を返す方法と、スキーマを渡す方法があります。
zod・valibot・ArkTypeのスキーマ直接指定
1系はStandard Schemaという共通仕様に対応しており、公式のValidationガイドはZod・Valibot・ArkType・Effect Schemaを挙げています。上の登録フォームのようにvalidators: { onChange: signupSchema }とフォーム単位で渡せば、各フィールドのerrorsにそのフィールドの分だけが配られます。古い記事にあるzodValidator()などのアダプタ設定は不要です。仕組みはStandard Schemaの全体像、スキーマの書き方はZodの使い方入門とValibotの解説にまとめています。
エラーは文字列ではなくissueオブジェクト
関数の検証で文字列を返した場合、errorsは文字列の配列です。スキーマを渡した場合はmessageやpathを持つオブジェクトの配列になり、zod 4.6.7のメール形式エラーはcode: "invalid_format"を持つオブジェクトでした。公式サンプルのfield.state.meta.errors.join(',')を流用すると画面に[object Object]と出るので、err?.messageを取り出します。
yupの非同期バリデーター指定
yup 1.7.1もStandard Schemaに対応していますが、検証結果を必ずPromiseで返します。これをonBlurに渡すと、フォーカスが外れた瞬間にError: async function passed to sync validatorが投げられます。型エラーにはならないので、実行するまで気づきません。onBlurAsync・onChangeAsyncに渡せば正しくエラーが入ります。
import * as yup from 'yup'
<form.Field
name="username"
validators={{
// onBlur に渡すと、フォーカスが外れた瞬間に
// Error: async function passed to sync validator
onBlurAsync: yup.string().required('必須です').min(3, '3文字以上で入力してください'),
}}
>
transformの変換結果とonSubmitでの再パース
スキーマのtransformで文字列を数値に変えても、1系のonSubmitに届くvalueは入力時の値のままです。公式のSubmission Handlingガイドも「The value passed to the onSubmit function will always be the input data.」と書いており、実測でもageは'20'(string)で届きました。変換後の値が必要ならonSubmitの中でもう一度parseします。v2ではこの再パースが不要になります(後述)。
const schema = z.object({ age: z.string().transform((v) => Number(v)) })
const form = useForm({
defaultValues: { age: '20' },
validators: { onChange: schema },
onSubmit: ({ value }) => {
// value.age は '20'(string)のまま届く
const parsed = schema.parse(value) // parsed.age は 20(number)
save(parsed)
},
})
非同期検証とデバウンス
ユーザー名の重複確認のようにAPIを呼ぶ検証はonChangeAsyncに書き、onChangeAsyncDebounceMsで間引きます。300msを指定して3回続けて入力した実測では、検証関数の呼び出しは最後の1回だけでした。同期と非同期の両方を書いた場合、非同期は同期側がエラーなしのときだけ走ります(asyncAlways: trueで常に実行)。
<form.Field
name="username"
validators={{
onChangeAsyncDebounceMs: 300,
onChangeAsync: async ({ value }) =>
(await isTaken(value)) ? 'このユーザー名は使われています' : undefined,
}}
>
送信処理:handleSubmitとサーバー側エラーの戻し方
form.handleSubmit()は全フィールドの検証を走らせ、エラーがなければonSubmitを呼びます。メールが不正なまま呼んだ実測では、onSubmitは一度も実行されませんでした。この節では、送信ボタンごとの処理の切り替え、サーバー側の検証結果の戻し方、未保存の変更の判定の3つを扱います。通信失敗やHTTPエラーの扱いはonSubmitの中で通常のtry・catchとして書きます。
- 送信ボタンで処理を分ける:
useFormにonSubmitMetaで既定値を置き、form.handleSubmit({ submitAction: 'continue' })のように引数を渡すと、onSubmitのmetaで受け取れます。「保存して続ける」「保存して一覧へ」を1つのフォームで扱えます。 - サーバーの検証結果を各欄に戻す:フォーム単位の
onSubmitAsyncで送信前にAPIを呼び、{ fields: { email: '登録済みです' } }の形で返すと、該当フィールドのエラーとして表示されます。 - 未保存の変更:
isDirtyは一度でも値を変えると、元に戻してもtrueのままです。初期値「a」を「b」にしてから「a」に戻した実測はisDirty: true・isDefaultValue: trueでした。「未保存の変更があります」の表示にはisDefaultValueを使います。
<form.Subscribe selector={(state) => state.isDefaultValue}>
{(same) => (same ? null : <p>未保存の変更があります</p>)}
</form.Subscribe>
配列フィールドとcreateFormHook・withFormによる部品化
mode=”array”がuseFieldArrayの代わり
React Hook FormのuseFieldArrayに当たる専用フックはなく、配列を持つフィールドにmode="array"を付けます。このとき値そのものではなく配列の変更回数を購読するため、要素の追加・削除のたびに一覧が描き直されます。操作はpushValue・removeValue・insertValue・swapValuesで行い、要素の各項目はname={`members[${i}].name`}のような添字付きの名前で描画します。この名前もテンプレート文字列のまま型推論されます。
createFormHookとwithFormによるフォームの部品化
公式のForm Compositionガイドは冒頭で「A common criticism of TanStack Form is that it is verbose out-of-the-box.」と認めており、その解決策がcreateFormHookです。自社のテキスト入力やボタンを一度登録すると、form.AppFieldの中からfield.TextFieldとして呼べます。大きなフォームを別ファイルに分けるときは、同じくcreateFormHookが返すwithFormで子コンポーネントを作り、親のformをpropsで渡します。次のコードは配列部分をwithFormで切り出した例で、tsc 7.0.2で型エラーなしを確認しました。
import { createFormHook, createFormHookContexts, formOptions } from '@tanstack/react-form'
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts()
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
return (
<label>
{label}
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
function SubmitButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(s) => s.isSubmitting}>
{(isSubmitting) => <button type="submit" disabled={isSubmitting}>{label}</button>}
</form.Subscribe>
)
}
export const { useAppForm, withForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TextField },
formComponents: { SubmitButton },
})
const profileOpts = formOptions({
defaultValues: { name: '', members: [] as { name: string }[] },
})
// 配列部分を別コンポーネントに切り出す
const MembersSection = withForm({
...profileOpts,
props: { title: 'メンバー' },
render: function Render({ form, title }) {
return (
<form.Field name="members" mode="array">
{(field) => (
<fieldset>
<legend>{title}</legend>
{field.state.value.map((_, i) => (
<form.AppField key={i} name={`members[${i}].name`}>
{(sub) => <sub.TextField label={`メンバー${i + 1}`} />}
</form.AppField>
))}
<button type="button" onClick={() => field.pushValue({ name: '' })}>
追加
</button>
</fieldset>
)}
</form.Field>
)
},
})
export function ProfileForm() {
const form = useAppForm({
...profileOpts,
onSubmit: ({ value }) => console.log(value),
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.AppField name="name">
{(field) => <field.TextField label="氏名" />}
</form.AppField>
<MembersSection form={form} title="参加メンバー" />
<form.AppForm>
<form.SubmitButton label="保存" />
</form.AppForm>
</form>
)
}
withFormに渡すdefaultValuesは型検査のためだけに使われるので、formOptionsで作った設定を親子で共有すると二重定義を避けられます。renderはアロー関数でなくfunction Renderの形で書きます。公式FAQによると、アロー関数ではESLintがフックの呼び出し位置を誤検知します。
エラーのある欄へフォーカスを移す機能はTanStack Formにありません。公式のFocus Managementガイドも「we cannot add a built-in focus management feature」としており、入力要素への参照は部品側で用意します。React Hook Formで同じ処理をするsetFocusの使い方はMUIとReact Hook Formの連携で扱っています。
React Hook Formとの違いと選び方
| 比較軸 | TanStack Form | React Hook Form |
|---|---|---|
| 安定版(2026-11-04) | 1.33.5 | 7.91.0 |
| 次期版 | 2.0.0-alpha.3 | 8.0.0-beta.5 |
| npm週間DL | 6,257,176 | 60,305,948 |
| 入力との結び方 | 制御型(value・handleChange) | register中心の非制御型 |
| スキーマ検証 | Standard Schemaを直接渡す | @hookform/resolvers経由 |
| 対応フレームワーク | React・Vue・Angular・Solid・Svelte・Lit | React |
| useForm単体のgzip実測 | 17,639バイト | 12,388バイト |
| フォーム全体の変更有無 | isDefaultValue | isDirty(初期値と比較) |
週間DLは2026年10月28日〜11月3日のnpm集計で、React Hook Formが約10倍です。サイズは、useFormをimportしてconsole.log(useForm)で参照を残した1ファイルを入口に、esbuild 0.28.2で--bundle --minify --format=esm --external:react --external:react-domを指定してバンドルし、gzip -9で圧縮した参考値です(参照を残さないと未使用コードとして除去されます)。使うAPIの範囲で値は変わります。
TanStack Formを選ぶ条件
- TanStack StartやTanStack Routerで組んでいて、サーバー関数と同じ型でフォームを検証したい(TanStack Startの解説参照)。
- ReactとVueなど複数のフレームワークで、検証ロジックと画面の考え方をそろえたい。
- 入力欄が数十ある業務フォームで、1欄の変更による再描画を
form.Subscribeの単位で抑えたい。アンケートや申請フォームのように設問が多い画面を作る場合は、アンケート管理システム開発のように画面と集計を一体で設計する選択肢もあります。
React Hook Formのままでよい条件
既存のReact Hook Formのフォームが問題なく動いているなら、置き換える理由は薄いと判断します。制御型への書き換えは入力部品とerrorsの表示箇所の全てに及び、さらにTanStack Form自体がv2で書き方を変える途中です。今移して将来v2も採用する場合は、1系への書き換えとv2への書き換えを続けて行うことになります。React Hook Formの現在の書き方はReact Hook Formの使い方とv7の書き方で解説しています。
TanStack Form v2 alphaの変更点と移行の判断
v2は2026年8月6日の公式ブログでalphaとして発表されました。ブログは「rebuilding the library from the ground up」と書いており、内部を作り直したうえで、v1で使いにくかった検証・リスナー・フォーム合成のAPIを設計し直しています。公式の移行ガイドから、日常のコードに影響する変更を抜き出すと次のとおりです。
| 項目 | v1(1.33.5) | v2(2.0.0-alpha.3) |
|---|---|---|
| 値・メタの参照 | field.state.value | field.value・field.meta |
| エラー | field.state.meta.errors | field.errors |
| 検証の指定 | onChangeなどのキー | run・triggersの配列 |
| デバウンス | onChangeAsyncDebounceMs | triggerDebounceMs |
| 配列 | mode=”array” | form.ArrayField |
| 部品の分割 | withForm | formプロップを受ける通常の部品 |
| 変換後の値 | onSubmit内で再parse | validatorOutputs |
| 動作環境 | React 17〜19・CJSあり | React 18以上・ESMのみ |
2.0.0-alpha.3を入れて確かめた結果は次の3点です。
- 1系のコードはそのまま通らない:
validators: { onChange: ... }・field.state.value・mode="array"を含むコードをtsc 7.0.2にかけると、次の3種類の型エラーになりました。移行は型エラーを1つずつ潰す作業になります。 - 変換後の値が
onSubmitに届く:下のコードで送信すると、value.ageは'20'、validatorOutputs[0].ageは数値の20でした。検証エラーがあるとonSubmitは呼ばれず、handleSubmit()の戻り値にエラーの配列が入ります。 - 移行ガイドの名前が古い:ガイド本文は
schemaOutputsと書いていますが、alpha.3の型定義にschemaOutputsはなく、リリースノートどおりvalidatorOutputsに改名済みです。alphaの間は、ガイドより型定義とリリースノートを優先して読みます。
error TS2353: Object literal may only specify known properties,
and 'onChange' does not exist in type 'readonly FormValidator<{ n: string; }>[]'.
error TS2339: Property 'state' does not exist on type 'ReactFieldApi<"n", string, ...>'.
error TS2322: ... Property 'mode' does not exist on type ...
// @tanstack/[email protected]
const form = useForm({
defaultValues: { email: '', age: '20' },
validators: [{ run: schema, triggers: ['change'] }],
onSubmit: async ({ value, validatorOutputs }) => {
// value.age は '20'(入力値)、validatorOutputs[0].age は 20(変換後)
await save(validatorOutputs[0])
},
})
<form.Field name="email">
{(field) => (
<input
value={field.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.meta.isInvalid}
/>
)}
</form.Field>
useForm単体のgzip実測は21,669バイトで、1.33.5より約4KB増えました。公式ブログは「faster runtime performance」をうたっていますが、ブラウザで操作したときの速さはこの記事では測っていません。Vueアダプタは、2026年11月4日時点でrc(3.6.0-rc.14)にとどまるVue 3.6を必須とし、パッケージにはAIコーディングエージェント向けのskills/ディレクトリが8つ同梱されています。
移行の判断ははっきりしています。本番のフォームは1.33.5に固定し、v2はalphaが外れて移行ガイドが型定義と一致するまで待ちます。上のvalidatorOutputsへの改名はalpha.2からalpha.3の1版で入ったものです(両版の型定義で確認)。逆に移行ガイドと公式APIリファレンスは、送信時に検証を飛ばす指定をskipOnSubmitと書いていますが、配布中のalpha.3の型定義にあるのはrunOnSubmitだけで、skipOnSubmitは未リリースです。alphaのコードは版を上げるたびに直す前提になります。新規に書くコードは、errorsをmessageで読む、defaultValuesを必ずフォーム単位で書くなど、v2でも変わらない書き方に寄せておくと移行の手間が減ります。
Vue・Svelte・React Native・Next.jsでの使い方
defaultValues・validators・onSubmitの設定はフレームワーク間で共通で、変わるのはフォームを作る関数と入力要素への結び方です。
- Vue・Svelte・Solid・Angular・Lit:フォームを作る関数は、Vueが
useForm、SolidとSvelteがcreateForm、AngularがinjectFormです。LitはTanStackFormControllerをコントローラーとして生成し、テンプレートから参照します。 - React Native:公式ガイドは「it should support React Native out-of-the-box」としており、追加設定はありません。
TextInputのonChangeTextにfield.handleChangeをそのまま渡します。 - Next.js App Router・TanStack Start:
@tanstack/react-form-nextjsなどの連携パッケージで、createServerValidateによりサーバー側に同じ検証を置き、返ってきた状態をmergeFormでクライアントのフォームへ戻します。
よくある質問
TanStack Formのnpmパッケージ名は何ですか?
Reactは@tanstack/react-formのようにフレームワーク名が入ります。通常のインストールで入るのは1.33.5で、v2を試す場合だけ@tanstack/react-form@alphaと指定します。
React Hook FormとTanStack Formはどちらが速いですか?
操作時の応答速度を同じ条件で比べた公式の計測はなく、この記事でも測っていません。数字で比べられるのはバンドルサイズで、useForm単体のgzip実測はTanStack Formが17,639バイト、React Hook Formが12,388バイトでした。
zodのエラーメッセージが[object Object]と表示されるのはなぜですか?
スキーマを渡したときのerrorsはオブジェクトの配列だからです。field.state.meta.errors.map((err) => err?.message)のようにmessageを取り出して表示してください。v2でもfield.errorsの要素はオブジェクトで、同じくmessageを読みます。
React Compilerと一緒に使えますか?
公式の比較表ではTanStack FormのReact Compiler対応は✅、React Hook Formは🛑です。ただし同じページに「This comparison table is under construction and is still not completely accurate.」とあり、Devtoolsを「Planned」とする行も0.2.34が出ている現状と合いません。React Compilerを有効にした状態で入力と送信の動作を確かめてください。
v2のalphaに移行すべきですか?
本番では移行しません。2.0.0-alpha.3(2026年10月23日)の時点でも改名を含む破壊的変更が続き、移行ガイドと型定義にもずれがあります。v2は安定版が出てから移行ガイドの順番どおりに進めます。