🌟

TanStack Formで巨大フォームを安全に分割する方法

に公開

1. はじめに

フロントエンドアプリケーションで、たくさんの入力項目を扱う巨大なフォームを構築する場面は少なくありません。
1つのフォームコンポーネント内に10個以上のフィールドが並び、それぞれにバリデーションや依存関係、条件分岐があるようなケースでは、コードはすぐに読みにくくなり、ちょっとした修正でも壊れやすくなってしまいます。
こうした問題を避けるためには、フォームを適切な単位で分割することが重要です。TanStack Form では withForm を使うことで、型安全かつ再利用性の高いフォーム分割が実現できます。
この記事では、TanStack Form の withForm を活用して、巨大フォームをどのように構造化・分割できるかを具体的に解説していきます。

2. TanStack Form における分割戦略の全体像

TanStack Form でフォームを分割する基本戦略は以下の通りです:

  • 親フォーム:アプリケーションの主要な useForm インスタンスを保持し、送信処理や全体的なバリデーションを担当する
  • 子フォームwithForm によって定義されたフォーム単位のコンポーネント。親から渡された form を使って、自身が担当するフィールドの定義とUIを持つ

このような構造にすることで、

  • UIの再利用性が高まる
  • 各コンポーネントの責務が明確になる
  • メンテナンス性が向上する

というメリットがあります。

子フォームは、フォームをセクション単位に分割するイメージで考えると分かりやすいです。
たとえば「名前入力」「住所入力」「支払い情報」など、意味的にまとまりのある単位ごとにフォームを切り出します。

具体的には以下のような構成になります:

<UserForm>                 // 親フォーム
  <UserNameForm />    // 子フォーム1(名前セクション)
  <UserAddressForm /> // 子フォーム2(住所セクション)
  <UserPaymentForm /> // 子フォーム3(支払いセクション)
</UserForm>

それぞれの子フォームは、自分が扱うフィールドのみに責任を持ち、UI・バリデーション・状態管理を局所的に完結させることができます。

3. 基盤となる useAppForm のセットアップ

フォームの共通機能を定義するため、まずは TanStack Form の createFormHookContextscreateFormHook を使って自前の useAppForm を作成します。

// hooks/useForm.ts
import { createFormHookContexts, createFormHook } from "@tanstack/react-form";

const { fieldContext, formContext, useFieldContext, useFormContext } =
    createFormHookContexts();

const fieldComponents = {}; // フィールドコンポーネントをここに定義する(例: { TextField, SelectField })
const formComponents = {}; // フォームコンポーネントをここに定義する(例: { SubmitButton })

export const { useAppForm, withForm } = createFormHook({
    fieldComponents,
    formComponents,
    fieldContext,
    formContext,
});

このカスタムフックを利用することで、どのフォームコンポーネントでも共通の型とバリデーションの土台が整った状態で開発を始められます。

fieldComponents には 独自定義したTextField や SelectField などの再利用可能なフィールドコンポーネントを、formComponents には SubmitButton などのフォーム全体で使用するコンポーネントを定義することで、アプリケーション全体で一貫したフォーム体験を提供できます。

4. 子フォームを withForm で切り出す

次に、巨大なフォームの一部を withForm を使って子コンポーネントとして切り出します。

// features/user/UserNameForm.tsx
import { withForm } from '@/hooks/useForm'

export const UserNameForm = withForm({
  // これらの値は型チェックのためのみに使用され、ランタイムでは使用されません
  defaultValues: {
    firstName: '',
    lastName: '',
  },
  props: { title: '' },
  render: function Render({ form, title }) {
    return (
      <>
        <h3>{title}</h3>
        <form.Field name="firstName">
          {(field) => <input value={field.state.value} onChange={e => field.handleChange(e.target.value)} />}
        </form.Field>
      </>
    )
  },
})

このように withForm を使うと、親から渡された form オブジェクトを使って子側が自由にフィールドをレンダリングできます。

注意点: render プロパティは、ESLintエラーを避けるために無名関数ではなく名前付き関数として定義することをオススメします。

5. 親フォームとの接続方法

子フォームを実際に使う親コンポーネントでは、以下のように useAppForm を使って form インスタンスを生成し、それを子に渡します:

// features/user/UserForm.tsx
import { useAppForm } from '@/hooks/useForm'
import { UserNameForm } from './UserNameForm'

export const UserForm = () => {
  const form = useAppForm({
    defaultValues: {
      firstName: '太郎',
      lastName: '山田',
    },
    onSubmit: async ({ value }) => console.log(value),
  })

  return (
    <form.AppForm>
      <UserNameForm form={form} title="名前情報" />
    </form.AppForm>
  )
}

このように、親フォームと子フォームは form を通じて明確につながります。form.AppForm コンポーネントラッパーが必要なコンテキストを提供し、子フォームが親のフォーム状態にアクセスできるようになります。

6. まとめ

TanStack Form の withForm を使うことで、巨大フォームを安全かつ効率的に分割できます。

  • 子フォームに責務を委ねることで、保守性と再利用性が向上する
  • 型推論やバリデーションも親から継承され、一貫性を担保できる
  • withFormdefaultValues は型チェックのためのみに使用され、実際の初期値は親フォームから提供される

本記事では触れませんでしたが、この構成からさらに lazySuspense を組み合わせてページ分割を行ったり、Valibot や Zod など外部スキーマ連携によるバリデーション強化も検討できるでしょう。

複雑なフォームを構築する際は、ぜひ一度 withForm を取り入れてみてください。

参考リンク

chot Inc. tech blog

Discussion