TanStack Form 使ってみた
TanStack Form は TanStack Query, TanStack Router で有名なTanStack系列のフォームライブラリです。まだv0系ですが活発に開発がされていて、v1リリースもそう遠くないと思われます。
トップページにはTanStackの公式推しポイント3点が挙げられています。
- 強力な型サポート
- Headless で多くのライブラリをサポート
- きめ細かな reactive で高パフォーマンス
それぞれの特徴について詳しく見ていく前に TanStack Form の基本的な書き方から見てみます。
基本的な使い方
useForm
フックから form インスタンスを取り出し、form.Field
でフォームの各 Field を定義していきます。そして form.Field
の children props に フォームの input 要素を入れてUIを構築していきます。Field First[1] な設計になっており、バリデーションの細かい設定などもFieldで定義できます。
export default function App() {
const form = useForm({
defaultValues: {
firstName: '',
lastName: '',
},
onSubmit: async ({ value }) => {
console.log(value)
},
})
return (
<div>
<h1>Simple Form Example</h1>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<div>
<form.Field
name="firstName"
validators={{
onChange: ({ value }) =>
!value
? 'A first name is required'
: value.length < 3
? 'First name must be at least 3 characters'
: undefined,
}}
children={(field) => {
return (
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</>
)
}}
/>
</div>
<div>
<form.Field
name="lastName"
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</>
)}
/>
</div>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
/>
</form>
</div>
)
}
第一印象として書き方が Formik に似ているなと思いました。個人的に Formik の書き方は分かりやすくて好きだったので、TanStack Form の書き方もすんなり理解できました。
また、コードを見て分かることとしてTanStack Form は 制御コンポーネントによる実装です。 ここは React Hook Form との大きな違いですね。制御コンポーネントということは入力のたびに再レンダリングが走りますが、後述するstate管理によってこの再レンダリングは最小限に抑えられます。
基本的な書き方が分かったところで、TanStack Form 公式の推しポイント3点を詳しく見ていきます。
強力な型サポート
TanStack Form は 100% TypesScript で書かれているので、型安全な開発が可能です。そして、Field の型補完が優秀です。 TanStack Form では各 Field をスキーマにマッピングするために name
を 文字列で指定しますが、ここに型補完が効きます。
React Hook Form でも register に指定する name
に型補完が効きますが、それと同じ体験が得られます。
Field 型補完の実装
form に与えられたスキーマオブジェクトの型を再帰的にチェックしてプロパティを文字列として抽出し、それら全てで文字列の union type を構築することでこの型補完を実現しています。このアプローチは React Hook Form と同じです。
TanStack Form の実装
React Hook Form の実装
配列のnameフォーマット
多くの Form ライブラリでは配列による動的なフォームをサポートしています。TanStack Form も配列の動的フォームのサポートをしていますが、その時のname
属性のフォーマットが特徴的です。
react-hook-form, formik などでは todos.0.name
と index を.
で繋ぎますが、TanStack Form では配列のindex指定に通常の配列と同じように[ ]を使って表現します。 個人的にこの指定の仕方は結構好きです。
<form.Field
name={`todos[${i}].name`}
/>
Headless で多くのライブラリをサポート
TanStack Form は headless ライブラリでUIを提供せず、Formに必要なロジックのみを提供します。 これによって開発者は独自のUIライブラリを使いつつ、TanStack Form による恩恵を受けることができます。フレームワークのサポートも充実しており、React, Vue, Angular, Solid, Lit をサポートしています。 Solid, Lit をサポートしている Form ライブラリは少ないので TanStack Form が対応してくれるのは嬉しいですね。
TanStack Form 公式ドキュメントのフレームワークタブを切り替えることで各フレームワークの実装例を確認することができます。
きめ細かな reactive で高パフォーマンス
TanStack Form では 再レンダリングの範囲を限定するというアプローチでパフォーマンス最適化をしています。
例えば、以下のfirstName, lastName を入力するシンプルなフォームで、ユーザがfirstNameを入力したときに再レンダリングが起きるのは、name="firstName"
を指定した form.Field
の children だけです。TanStack Form では form.Field
を使ってフォームを組み立てていくでの、特に意識しなくてもレンダリング最適化されたformを実装することができます。 この仕組みについては記事の後半で解説します。
export default function App() {
console.log("render App") // <- FIeld の入力では実行されない
return (
<form>
<div>
<form.Field
name="firstName"
children={(field) => {
console.log("first name が変化した時に実行されます。")
return (
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</>
)
}}
/>
</div>
<div>
<form.Field
name="lastName"
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</>
)}
/>
</div>
</form>
)
}
個人的にTanStack Form でいいなと思ったこと
TanStack Form 公式が掲げる3つの特徴以外で良いなと思ったことを書いていきます。
Field First で柔軟なバリデーションが定義できる
TanStack Form では Field
にバリデーションを定義しますが、そこでバリデーションのタイミングを設定できます。 なので、Field ごとにバリデーションのタイミングを変えるといったことも簡単にできます。
以下の例では firstName は変更時にバリデーションが走り、lastName はブラー時にバリデーションが走ります。
<form.Field
name="firstName"
validators={{
onChange: ({ value }) => // 変更時にバリデーション
!value
? "A first name is required"
: undefined,
}}
<form.Field
name="lastName"
validators={{
onBlur: ({ value }) => // ブラー時にバリデーション
!value
? "A first name is required"
: undefined,
}}
さらに、onChangeListenTo
というオプションもあり、他の Field を指定することで、指定した Field に変更があった時にバリデーションを走らせるということも可能です。
ドキュメントにはパスワードフォームで、パスワード Field が変更された時に、確認パスワード Field にバリデーションが走る実装を例に挙げていました。
<form.Field name="password">
{(field) => (
<label>
<div>Password</div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)}
</form.Field>
<form.Field
name="confirm_password"
validators={{
onChangeListenTo: ['password'], // password Field が変更された時にもバリデーションが実行される
onChange: ({ value, fieldApi }) => {
if (value !== fieldApi.form.getFieldValue('password')) {
return 'Passwords do not match'
}
return undefined
},
}}
></form.Field>
Field 単位ではなく、Form 単位でバリデーションを行うことも可能で、useForm
に validators
を指定することで実現できます。
const form = useForm({
defaultValues: {
age: 0,
},
validators: {
onChange({ value }) {
if (value.age < 13) {
return 'Must be 13 or older to sign'
}
return undefined
},
},
})
また他のフォームライブラリと同じように Zod
, Yup
Valibot
を使用したバリデーションにも対応しています。
Next.js の server action に対応
現状、server action 対応の フォームライブラリは多くないので、server action を使ったフォームを作成するときは TanStack Form は有力な選択肢になりそうです。
開発が活発に行われている
2024年7月現在、v1リリースに向けて活発に開発が進められており、週1以上のペースでリリースが行われています。
以下の記事でも言及されていますが、意外とメンテされているフォームライブラリは多くないのでTanStack Form には期待が高まります。
V1リリースまでに優先的に取り組む課題が以下の issue にまとまっていました。APIの変更はせず、今のバグ修正が終わったらリリースする予定らしいです。
リリースも近そうです!
最後に TanStack Form の内部実装について少し覗いてみます。
TanStack Form の state 管理
先ほど TanStack Form は 再レンダリングの範囲が小さくなるように設計されていると書きましたが、単純に useState
などのリアクティブな state api を使ってformコンポーネントで一元的に Field の値を管理すると、1つの Field の値を変更するたびにform全体が再レンダリングされてしまします。
TanStack Form ではオブザーバーパターンを利用してサブスクライブしている場所だけが再レンダリングされるように設計されており、きめ細かなリアクティブを実現しています。
このオブザーバーパターンの store 管理には TanStack Store が利用されています。このライブラリも名前の通り TanStack 系列のライブラリです。あまり他での使用例は見たことがありませんが、TanStack Form の他に、TanStack Router でも使われているようです。
TanStack Store の実装を見るとコア部分の実装が100行未満でとてもシンプルなstate管理ライブラリでした。
React Hook Form との違い
React Hook Form もレンダリング最適化にオブザーバーパターンのアーキテクチャを採用しています。よって実装の詳細は少し異なりますが、大まかな方針は同じということになります。
レンダリング最適化文脈での、TanStack Form と React Hook Form の違いは React Hook Form が非制御コンポーネントで TanStack Form は 制御コンポーネントであるといことです。 TanStack Form は制御コンポーネントである以上、Field に入力を行った時に必ず1回は Field 要素に対して再レンダリングが走ります。対して 非制御コンポーネントである React Hook Form では再レンダリングが走りません。ただし、このケースで再レンダリングされる箇所は限定的なため、この違いによるパフォーマンスがUXに与える影響は無視できるレベルだと思います。
Field を変更したときの処理の流れ
具体的にどのようにオブザーバーパターンが動作するのかをコードを読んで調べてみました。ここでは、ユーザーが Field に入力したときを例に処理の流れを解説します。
ユーザーがformの Field に入力を行うと ① FieldApi
の handleChange
メソッドが呼ばれます。 その後、② FormApi
で一元管理されているStoreに対して変更の合ったFieldの更新を行います。
FieldApi
の中にも field の値を管理する store があります。FieldApi
ではFormStore をsubscribeしており、③ FormStore に変更があると、FiedApi は通知を受けとり、Field Store を更新します。
④ Field Store は useField フックで参照されており、Field Store が更新されると useField
に再レンダリングが走ります。フックは<Field/>内部で参照しているため、<Field/> の children が再レンダリングされてUIが更新されます。
細かい部分の処理は省略していますが、大まかにこのような流れでUIが更新されます。
Field の値を監視したいとき
前述した処理により、普通に実装すると Field が変化した時に再レンダリングされるのは Field のchildren
に渡した要素だけです。
しかし、Field に入力した内容を即座に画面に反映したい時があると思います。これを実現するReact Hook Form の watch や useWatch に相当する機能が TanStack Form にもあります。
1つはuseStore
を使う方法でstate.values.{fieldName}
で指定したFieldを監視して変更があると再レンダリングしてくれます。
const firstName = form.useStore((state) => state.values.firstName)
もう1つの方法は form.Subscribe
を使う方法です。useStore
値の変更時に使用したコンポーネントが再レンダリングされますが、form.Subscribe
を使った場合、再レンダリングされるのはchildren
に指定した要素だけになります。なので監視対象を表示のロジックに使いたいなら、form.Subscribe
を使った方がパフォーマンスを最適化しやすいです。
<form.Subscribe
selector={(state) => [state.values.firstName]}
children={([firstName]) => (
<>
{firstName}
</>
)}
/>
まとめ
TanStack Form は安全で強力な型サポートでDXが良く、最適化されたState戦略でパフォーマンスも良いフォームライブラリでした。バリデーションの柔軟性も高く、シンプルながら複雑な要件のフォームにも耐えうるライブラリだと思います。対応フレームワークも多いので、今後使われる機会も増えてくると思います。特にFormikの書き方が好きだが、パフォーマンスや型補完に不満がある人に刺さるライブラリだと思いました。
現状v0でバグも少し残っているようなので、すぐに本番環境に適用とはいかないかもしれませんが、強力なフォームライブラリなので、今後の動向を見つつ本番環境での利用も積極的に考えていきたいと思います!
Discussion