🌅

TanStack Form 使ってみた

2024/07/29に公開

TanStack FormTanStack Query, TanStack Router で有名なTanStack系列のフォームライブラリです。まだv0系ですが活発に開発がされていて、v1リリースもそう遠くないと思われます。

トップページにはTanStackの公式推しポイント3点が挙げられています。

  • 強力な型サポート
  • Headless で多くのライブラリをサポート
  • きめ細かな reactive で高パフォーマンス

https://tanstack.com/form/latest

それぞれの特徴について詳しく見ていく前に 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 の実装
https://github.com/TanStack/form/blob/2bebfd5214c4cdfbf6feacb7b1e25a6825957062/packages/form-core/src/util-types.ts

React Hook Form の実装
https://github.com/react-hook-form/react-hook-form/blob/61524aeb83d78e8ffc34221b1db638582d4fbff4/src/types/path/eager.ts

配列の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,
  }}

https://tanstack.com/form/latest/docs/reference/FieldValidators

さらに、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>

https://tanstack.com/form/latest/docs/framework/react/guides/linked-fields

Field 単位ではなく、Form 単位でバリデーションを行うことも可能で、useFormvalidators を指定することで実現できます。

  const form = useForm({
    defaultValues: {
      age: 0,
    },
    validators: {
      onChange({ value }) {
        if (value.age < 13) {
          return 'Must be 13 or older to sign'
        }
        return undefined
      },
    },
  })

https://tanstack.com/form/latest/docs/framework/react/guides/validation#validation-at-field-level-vs-at-form-level

また他のフォームライブラリと同じように Zod, Yup Valibot を使用したバリデーションにも対応しています。

https://tanstack.com/form/latest/docs/framework/react/guides/validation#adapter-based-validation-zod-yup-valibot

Next.js の server action に対応

現状、server action 対応の フォームライブラリは多くないので、server action を使ったフォームを作成するときは TanStack Form は有力な選択肢になりそうです。
https://tanstack.com/form/latest/docs/framework/react/guides/ssr#using-tanstack-form-in-a-nextjs-app-router

開発が活発に行われている

2024年7月現在、v1リリースに向けて活発に開発が進められており、週1以上のペースでリリースが行われています。
https://github.com/TanStack/form/releases?page=1

以下の記事でも言及されていますが、意外とメンテされているフォームライブラリは多くないのでTanStack Form には期待が高まります。
https://zenn.dev/manalink_dev/articles/winter-react-form-mokumoku-meetup-report?redirected=1

V1リリースまでに優先的に取り組む課題が以下の issue にまとまっていました。APIの変更はせず、今のバグ修正が終わったらリリースする予定らしいです。

https://github.com/TanStack/form/issues/813

リリースも近そうです!
https://x.com/crutchcorn/status/1809484377488752901

最後に 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管理ライブラリでした。

https://zenn.dev/morinokami/books/learning-patterns-1/viewer/observer-pattern

https://tanstack.com/store/latest

React Hook Form との違い

React Hook Form もレンダリング最適化にオブザーバーパターンのアーキテクチャを採用しています。よって実装の詳細は少し異なりますが、大まかな方針は同じということになります。

https://speakerdeck.com/kotarella1110/react-hook-form-hadofalseyounizai-rendaringuwozui-shi-hua-siteirufalseka

レンダリング最適化文脈での、TanStack Form と React Hook Form の違いは React Hook Form が非制御コンポーネントで TanStack Form は 制御コンポーネントであるといことです。 TanStack Form は制御コンポーネントである以上、Field に入力を行った時に必ず1回は Field 要素に対して再レンダリングが走ります。対して 非制御コンポーネントである React Hook Form では再レンダリングが走りません。ただし、このケースで再レンダリングされる箇所は限定的なため、この違いによるパフォーマンスがUXに与える影響は無視できるレベルだと思います。

Field を変更したときの処理の流れ

具体的にどのようにオブザーバーパターンが動作するのかをコードを読んで調べてみました。ここでは、ユーザーが Field に入力したときを例に処理の流れを解説します。

ユーザーがformの Field に入力を行うと FieldApihandleChange メソッドが呼ばれます。 その後、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 の watchuseWatch に相当する機能が 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}
    </>
  )}
/>

https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts#reactivity

まとめ

TanStack Form は安全で強力な型サポートでDXが良く、最適化されたState戦略でパフォーマンスも良いフォームライブラリでした。バリデーションの柔軟性も高く、シンプルながら複雑な要件のフォームにも耐えうるライブラリだと思います。対応フレームワークも多いので、今後使われる機会も増えてくると思います。特にFormikの書き方が好きだが、パフォーマンスや型補完に不満がある人に刺さるライブラリだと思いました。

現状v0でバグも少し残っているようなので、すぐに本番環境に適用とはいかないかもしれませんが、強力なフォームライブラリなので、今後の動向を見つつ本番環境での利用も積極的に考えていきたいと思います!

脚注
  1. TanStack Form のドキュメントに Field First という言葉は登場しませんが、TanStack Form の開発に合流した HouseForm が Field Fiest を掲げており、その特徴がTanStack にも引き継がれていると思います。 ↩︎

AI Shift Tech Blog

Discussion