📝

TanStack Form と React Hook Form の Dirty 判定の挙動を比較してみた

に公開

はじめに 🚩

React Hook Form は多くの現場で定番のフォームライブラリですが、最近は TanStack Form v1 のリリースによって、フォーム実装の選択肢がさらに広がっているように思います。

突然ですが、実際の開発では「フォームのどのフィールドが変更されたか」を正確に知りたい場面がよくあります。そこで本記事では、TanStack Form と React Hook Form の「Dirty 判定」の挙動の違いについて整理していきます。

たとえば規模が大きいプロジェクト(フィールド数が多いフォーム)では、「変更されたフィールドだけを更新したい」といった要件が存在します。具体的には、変更されたフィールドだけを保存して DB 負荷を抑えるといったケースです。

ただし、両ライブラリごとにDirty(変更済み)フィールドの判定方法や考え方が異なります。この違いを理解せずにライブラリを選ぶと、思わぬ落とし穴にはまることもあるので注意が必要です。

まずは両者の違いをざっくり比較してみます。

ライブラリ Dirty 判定の考え方
TanStack Form 「一度でも編集されたら isDirty = true」  →  元の値に戻しても true のまま
React Hook Form 「現在値 ≠ 初期値」で Dirty を判定 →  元の値に戻せば false

このように、TanStack Form は「一度でも編集されたら isDirty = true」という履歴型の判定を採用しています。

ただし、TanStack Form のコミュニティからは「現在値 ≠ 初期値」のような差分型の判定も求められており、そのため今後は「isDefaultValue」の導入が進められています。これにより、将来的には履歴型と差分型の両方のアプローチを柔軟に使い分けられるようになる予定です。こちらに関しては後述します。

なぜ TanStack Form は React Hook Form と異なる Dirty 判定を採用したのか?

TanStack Form が後発のライブラリでありながら、React Hook Form とは異なる「履歴型」の Dirty 判定を採用しているのには、いくつか理由があると考えられます。

まず、ユーザーが実際にフィールドを操作したかどうかを明確に追跡できる点が挙げられます。フォームでは「値が変わったか」だけでなく、「ユーザーがそのフィールドに触れたかどうか」という情報も重要です。特にバリデーションや UX 設計の観点から、「ユーザーが操作した」という事実を保持しておきたい場面が多くあります。

また、技術的な観点では、値の深い比較を常に行う必要がなく、「操作があったかどうか」だけを記録するシンプルな実装になっているため、特に大規模なフォームでパフォーマンス面のメリットがあると考えられます。

そのため、React Hook Form のような「現在値 ≠ 初期値」の判定を採用していると、フォームの履歴管理が複雑になり、パフォーマンスの低下やバグの発生を招く可能性があります。


[参考]

本記事で扱う「履歴型」と「差分型」の定義

既に出てきていますが、改めて本記事ではフォームの「Dirty(変更済み)」判定の考え方として、以下の 2 つの型を定義します。

履歴型(TanStack Form の isDirty

  • 一度でもフィールドを編集したら、その後元の値に戻しても「変更あり」と判定します。
  • 「ユーザーがこのフィールドを操作したかどうか」を記録するイメージです。
  • たとえば、名前を「山田」→「佐藤」→「山田」と変更した場合、最終的な値は初期値と同じでも dirty=true となります。

差分型(React Hook Form の isDirty

  • 現在の値が初期値と異なる場合のみ「変更あり」と判定します。
  • 「今この瞬間、初期値とどこが違うか」を示すイメージです。
  • たとえば、名前を「山田」→「佐藤」→「山田」と変更した場合、最終的な値が初期値と同じなら dirty=false となります。

1 TanStack Form ─ 「初期値との差分」を自前比較で検出 ✅

TanStack Form には 「現在値 ≠ 初期値」を返すフラグがまだ無い ため、defaultValuesuseStore(form.store, s => s.values) が返す現在値を比較するヘルパーを自作する必要があります。

1-1 フォーム定義

事前にフォームを定義します。

import { useForm } from "@tanstack/react-form";

type FormValues = {
  name: string;
  age: number;
  address: { city: string; zip: string };
  tags: string[];
};
export const form = useForm({
  defaultValues: {
    name: "",
    age: 0,
    address: { city: "", zip: "" },
    tags: [],
  } satisfies FormValues,
  onSubmit: async ({ value }) => {
    // Do something with form data
    console.log(value);
  },
});

1-2 Dirty パス配列を返すカスタムフック

このカスタムフックは、TanStack Form の useStore を使って現在のフォーム値と初期値を比較し、変更されたフィールドのパスを平坦配列で返します。

この実装は、次章で紹介する RHF のディスカッションで提案されている方法を参考にしています。

import { useStore } from "@tanstack/react-form";
import { isDeepEqual } from "remeda";

type FormOf<T> = ReactFormExtendedApi<T>;

/** 現在 Dirty なフィールドパスを平坦配列で返す */
export const useDirtyKeys = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  // 現値と初期値だけ購読して再レンダを最小化
  const values = useStore(form.store, (s) => s.values);
  const defaults = form.options.defaultValues;

  // 深い比較で異なるパスを収集
  const getDirtyFieldPaths = (
    current: any,
    defaultValue: any,
    basePath = ""
  ): string[] => {
    // プリミティブまたは null の場合は比較して終了
    if (
      typeof current !== "object" ||
      current === null ||
      defaultValue === null
    ) {
      return !isDeepEqual(current, defaultValue) ? [basePath] : [];
    }

    // オブジェクトまたは配列の場合は再帰的に処理
    return Object.keys({ ...current, ...defaultValue }).flatMap((key) => {
      const path = basePath ? `${basePath}.${key}` : key;
      const currentValue = current[key];
      const defaultVal = defaultValue?.[key];

      // 値に違いがあるか確認
      if (!isDeepEqual(currentValue, defaultVal)) {
        // さらにオブジェクトなら再帰、そうでなければパスを返す
        return typeof currentValue === "object" && currentValue !== null
          ? getDirtyFieldPaths(currentValue, defaultVal, path)
          : [path];
      }

      return []; // 変更なしの場合は空配列
    });
  };

  return getDirtyFieldPaths(values, defaults);
};

// 使用例
const dirtyKeys = useDirtyKeys(form);
// 例えば、name と address.city が変更されているとき
console.log(dirtyKeys); // ['name', 'address.city']

なぜ remeda.isDeepEqual を使うのか?

単純に JSON.stringify で比較する方法も考えられますが、オブジェクトのキー順序の違いや Date オブジェクトの扱いなどで予期せぬ誤差が生じる可能性があります。そのため、オブジェクトの深い比較に特化した remeda.isDeepEqual のようなユーティリティを利用することで、より確実に値の同一性を判定できます。

1-3 派生ストアでパフォーマンス最適化

form.store.derive() を使うことで Derived Store(派生ストア)を作成できます。
Derived Store は、元の Store の値から新しい値(ここでは「差分キー配列」)を計算して保持するストアです。

export const createDirtyStore = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  return form.store.derive((s) =>
    getDirtyFieldPaths(s.values, form.options.defaultValues)
  );
};

export const useDirtyKeysDerived = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  const dirtyStore = useMemo(() => createDirtyStore(form), [form]);
  return useStore(dirtyStore);
};

// 使用例
const dirtyKeys = useDirtyKeysDerived(form);
// 例えば、name と address.city が変更されているとき
console.log(dirtyKeys); // ['name', 'address.city']

ここで重要なのは、form.store.derive() のコールバック内で deep-diff(getDirtyFieldPaths)の計算を行っている点です。

この派生ストアは state.valuesform.options.defaultValues だけを購読しているため、Dirty 状態の変化以外では再レンダが発生しません

つまり、「差分キー配列だけを 1 つの store に閉じ込めて購読」することで、フォーム全体のパフォーマンスを最適化できます。
大規模なフォームでは、この派生ストアを使うことでパフォーマンスを大幅に向上させることができます。

1-4 差分オブジェクトで PATCH 送信

このカスタムフックは、現在のフォーム値と初期値を比較して変更された部分だけを含むオブジェクトを返します。

こちらも、RHF のコミュニティディスカッションで提案されている実装を参考にしています。

type FormOf<T> = ReactFormExtendedApi<T>;

/**
 * オブジェクトから指定されたパスの値を深く取得します。
 * @param {Record<string, any>} obj - 値を取得する対象のオブジェクト。
 * @param {string} path - 値を取得するためのドット区切りのパス文字列。
 * @returns {any} 取得した値。パスが存在しない場合は undefined を返します。
 */
const getValueByPath = (obj: any, path: string): any => {
  if (!path) return undefined;
  const parts = path.split(".");
  let current = obj;
  for (const part of parts) {
    if (current && typeof current === "object" && part in current) {
      current = current[part];
    } else {
      return undefined;
    }
  }
  return current;
};

/**
 * オブジェクトの指定されたパスに値を深く設定します。
 * @remarks
 * この関数は `obj` 引数で渡されたオブジェクトを直接変更(ミューテーション)します。
 * イミュータブルな操作が必要な場合は、各階層で新しいオブジェクトを作成するよう実装を修正してください。
 * ここでは簡潔さのためミューテーションを許容する実装としていますが、
 * プロジェクトのコーディング規約によってはイミュータブルな実装が推奨されます。
 * @param {Record<string, any>} obj - 値を設定する対象のオブジェクト。
 * @param {string} path - 値を設定するためのドット区切りのパス文字列。
 * @param {any} value - 設定する値。
 * @returns {void}
 */
const setValueByPath = (obj: any, path: string, value: any): void => {
  if (!path) return;
  const parts = path.split(".");
  let current = obj;
  for (let i = 0; i < parts.length - 1; i++) {
    const part = parts[i];
    if (!current[part] || typeof current[part] !== "object") {
      current[part] = {}; // 途中のパスがなければオブジェクトを作成
    }
    current = current[part];
  }
  current[parts[parts.length - 1]] = value;
};

/**
 * TanStack Form のインスタンスを受け取り、変更されたフィールドの値のみを含むオブジェクトを返すカスタムフックです。
 * `isDefaultValue` プロパティを利用して、初期値から変更があったフィールドを特定します。
 *
 * @template TValues - フォームが扱う値の型。 `Record<string, any>` を継承する必要があります。
 * @param {FormOf<TValues>} form - TanStack Form のインスタンス。`FormOf` の型定義は記事の文脈で提供されることを想定しています。
 * @returns {Partial<TValues>} 変更されたフィールドとその値のみを含むオブジェクト。
 */
export const useDirtyPayload = <TValues extends Record<string, any>>(
  form: FormOf<TValues>
) => {
  const values = useStore(form.store, (s) => s.values as TValues);
  const fieldMeta = useStore(form.store, (s) => s.fieldMeta);

  // 変更されたフィールドだけを含むオブジェクトを構築
  return Object.entries(fieldMeta)
    .filter(([, meta]) => !meta.isDefaultValue)
    .reduce((result, [key]) => {
      const valueToSet = getValueByPath(values, key);
      if (valueToSet !== undefined) {
        setValueByPath(result, key, valueToSet);
      }
      return result;
    }, {} as Partial<TValues>);
};

// 使用例
const payload = useDirtyPayload(form);
// 例えば、name と address.city が変更されているとき
console.log(payload); // { name: 'foo', address: { city: 'bar' } }
// API 送信
fetch("/api/profile", { method: "PATCH", body: JSON.stringify(payload) });
送信後にデフォルト値を更新したい場合

ユーザーが「保存」した直後に その時点の値 を新しい defaultValues
として採用したいケースでは、次のどちらかを呼び出します。

form.reset(newValues); // 全フィールドをリセットし、同時に defaultValues を置換
// あるいは
form.setDefaultValues(newValues); // 状態は保持したまま defaultValues だけ更新

これにより form.options.defaultValues が書き換わります。

2 React Hook Form ─ 「変更されたフィールド」をネスト構造で検知 ✅

RHF には formState.dirtyFields が標準で用意されており、これを活用して変更されたフィールドの処理が可能です。

2-1 変更値を抽出する getDirtyValues 関数

React Hook Form の dirtyFields を効果的に活用するには、変更された値だけを抽出するユーティリティがあると便利です。
React Hook Form のコミュニティディスカッションで提案されている getDirtyValues 関数が最も実用的なアプローチだと思います。

export type DirtyFieldsType =
  | boolean
  | null
  | {
      [key: string]: DirtyFieldsType;
    }
  | DirtyFieldsType[];

/**
 * dirtyFieldsから変更されたフィールドの値だけを抽出する関数
 * @param dirtyFields - フォームの変更状態を表すオブジェクト
 * @param values - フォームの現在の値
 * @returns 変更されたフィールドとその値のみを含むオブジェクト
 */
export function getDirtyValues<T extends Record<string, any>>(
  dirtyFields: Partial<Record<keyof T, DirtyFieldsType>>,
  values: T
): Partial<T> {
  const dirtyValues = Object.keys(dirtyFields).reduce((prev, key) => {
    const value = dirtyFields[key];
    // 変更されていないフィールドはスキップ
    if (!value) {
      return prev;
    }
    const isObject = typeof value === "object";
    const isArray = Array.isArray(value);
    // ネストされたオブジェクトの場合は再帰的に処理
    const nestedValue =
      isObject && !isArray
        ? getDirtyValues(value as Record<string, any>, values[key])
        : values[key];
    // 配列の場合は配列全体を返し、オブジェクトの場合は変更された部分のみ返す
    return { ...prev, [key]: isArray ? values[key] : nestedValue };
  }, {} as Partial<T>);
  return dirtyValues;
}

この getDirtyValues 関数で実現できる主な機能:

  1. PATCH リクエスト向けデータ生成: 変更されたフィールドの値だけを含むオブジェクトが得られる
  2. 変更フィールドの把握: Object.keys(getDirtyValues(dirtyFields, values)) で変更フィールドのキーが取得できる

配列フィールドの扱いには注意が必要で、この実装では「配列内要素が変更された場合は配列全体を変更対象とする」というシンプルな方針を取っています。

React Hook Form の formState.dirtyFields はリアルタイム差分 です。
現在値が defaultValues と異なるフィールドだけを保持しており、値を初期値に戻すと自動でそのキーが削除されます。
したがって「一度でも編集したら永久に true」という履歴指向ではありません。

2-2 フォーム全体での使用例

import { useForm } from "react-hook-form";
import { getDirtyValues, DirtyFieldsType } from "./form-utils"; // 上記関数をインポート

type FormValues = {
  name: string;
  age: number;
  address: { city: string; zip: string };
  tags: string[];
};

export default function ProfileForm({
  defaultValues,
}: {
  defaultValues: FormValues;
}) {
  const {
    register,
    formState: { dirtyFields, isDirty },
    handleSubmit,
  } = useForm<FormValues>({
    defaultValues,
  });

  const onSubmit = (data: FormValues) => {
    // 変更されたフィールドの値だけを含むオブジェクトが得られる
    const dirtyValues = getDirtyValues(dirtyFields, data);
    // 例えば、name と address.city が変更されているとき
    console.log(dirtyValues); // { name: 'foo', address: { city: 'bar' } }

    // 変更されたフィールド名一覧を平坦配列化
    const diffKeys = Object.keys(dirtyValues);
    console.log(diffKeys); // 例: ['name', 'address']

    // APIに送信
    fetch("/api/profile", {
      method: "PATCH",
      body: JSON.stringify(dirtyValues),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} placeholder="名前" />
      <input {...register("age")} type="number" placeholder="年齢" />
      <input {...register("address.city")} placeholder="都市" />
      {/* 他フィールド略 */}

      <button type="submit" disabled={!isDirty}>
        保存
      </button>
    </form>
  );
}

この方法を使うことで、変更されたフィールドだけを抽出して送信できるため、PATCH リクエスト時のデータ転送量を効率的に削減できます。

2-3 フィールド単位の isDirty 判定

フォーム全体の変更状態だけでなく、個別フィールドの変更状態も簡単に取得できます。
useController フックを使えば、特定フィールドの isDirty 状態を取得できます:

const { control } = useForm({
  defaultValues: { username: "foo" },
});

const {
  fieldState: { isDirty },
} = useController({ name: "username", control });

// isDirty === false → まだ初期値 ('foo') と同じ
// 値を変更すると true、再度 'foo' に戻すと false に戻る

ポイントは useForm で defaultValues を必ず指定 すること。
デフォルト値が未定義だと比較対象がなくなり isDirty が期待とずれるので注意です。

useController を挟まずに register だけで取得したい場合はformState.dirtyFields[name] を見るか、getFieldState(name) を呼びます。

3 パフォーマンス Tips 🏎️

3-1 RHF における Proxy と購読最適化

React Hook Form は Proxy ベースの遅延評価 を採用し、
参照された FormState のプロパティだけ を購読対象にする」ことで
大規模フォームでも再レンダリングと計算コストを最小化します。

// 自動的に購読フラグが立ち、必要なプロパティだけ更新される
const { dirtyFields } = useFormState({ control });

Proxy による遅延評価

React Hook Form(RHF)は、Proxy ベースの遅延評価を採用しています。
この仕組みの中心となるのが getProxyFormState 関数です。
この関数は FormState オブジェクト全体を Proxy でラップし、アクセスされたプロパティのみを購読対象にします。

この設計のポイントは、プロパティにアクセスしたタイミングで対応するフラグが自動的に設定されることです。
たとえば formState.dirtyFields にアクセスした場合にだけ dirty 判定が実行されるため、不要な計算や再レンダーを抑えることができます。
つまり、実際に使われているプロパティだけが監視対象となるので、フォームのパフォーマンスを効率よく最適化できます。

参考:

useFormState による最適化

// 親コンポーネント
function ParentForm() {
  const { control } = useForm();
  return (
    <form>
      <Input name="name" control={control} />
      <ErrorDisplay control={control} />
    </form>
  );
}

// 子コンポーネント(エラーだけを購読)
function ErrorDisplay({ control }) {
  // 親に影響を与えず、エラー変更時のみ再レンダー
  const { errors } = useFormState({ control });
  if (!errors.name) return null;
  return <p>{errors.name.message}</p>;
}
  • useFormState を使うと コンポーネント単位で必要な状態だけを購読 できます
  • フォーム全体の状態を親で購読すると、どこか一箇所の変更で全体が再レンダーされる問題を解消
  • name プロパティを指定すれば特定フィールドのみ監視も可能

DirtyFields の内部更新最適化

以下のコードで dirtyFields の更新が最適化されていることがわかります。

https://github.com/fullstackhouse/react-hook-form/blob/5e4429ebaf2d792c10be12ffbdf668a1987aff4b/src/logic/createFormControl.ts#L334-L356

具体的には以下のようなロジックで更新されています。

  • フィールド更新時、購読フラグが立っている場合のみ dirtyFields 全体を再計算
  • 通常は変更のあった特定フィールドだけ set/unset するため効率的
  • dirtyFields オブジェクトはミュータブルに更新され、必要な再レンダー通知だけを行う設計

3-2 TanStack Form のストア購読と派生値

TanStack Form は Store 指向 のため、購読とリアクティビティに異なるアプローチを取ります。

// fieldMeta から isDirty=true の件数を数える
const dirtyCount = form.useStore(
  (s) => Object.values(s.fieldMeta).filter((m) => m.isDirty).length
);

ストア購読の最適化

TanStack Form では、useStore フックを使ってフォーム状態を購読します。公式ドキュメントでも「セレクタを使って必要な状態だけを購読すること」が推奨されています。

https://tanstack.com/form/latest/docs/framework/react/guides/reactivity

  • form.useStore(selector) は内部的に useSyncExternalStoreWithSelector を利用しており、セレクタが返す値を浅い比較で判定します。
  • セレクタで選択した値だけを監視し、前回と異なる場合のみ再レンダリングが発生します。
  • オブジェクトをそのまま返さず、プリミティブや配列・値そのものを返すことが重要です(例:{ a: state.a } ではなく state.a を直接返す)。これにより、不要な再レンダリングを防げます。

また、公式ドキュメントでも「セレクタを省略すると不要な再レンダリングが発生する可能性がある」と警告されています。

While it IS possible to omit the selector, resist the urge as omitting it would result in many unnecessary re-renders whenever any of the form state changes.

最適化のためには、常にセレクタを明示的に指定し、必要な値だけを購読することがベストプラクティスです。

派生ストアによる計算コスト最適化

既に 1-3 で触れた内容ではありますが、パフォーマンスに関して再度整理します。

派生ストアを再掲
export const createDirtyStore = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  return form.store.derive((s) =>
    getDirtyFieldPaths(s.values, form.options.defaultValues)
  );
};

export const useDirtyKeysDerived = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  const dirtyStore = useMemo(() => createDirtyStore(form), [form]);
  return useStore(dirtyStore);
};

// 使用例
const dirtyKeys = useDirtyKeysDerived(form);
// 例えば、name と address.city が変更されているとき
console.log(dirtyKeys); // ['name', 'address.city']
  • form.store.derive()派生値 を遅延評価で作成可能
  • 依存元(フォーム状態)が変わったときだけ再計算されるため効率的
  • 複雑な深い比較などを派生ストアの中だけで行い、比較結果だけを返すことでパフォーマンス向上
  • コンポーネントは計算結果だけを購読するため、再レンダリングが最小限に抑えられる

参考:

3-3 配列操作時の注意点

RHF の配列操作と dirty 判定

  • useFieldArray で要素を追加・削除すると、デフォルト値との比較で 配列全体 が変化したと判断される
  • 例:10 個の配列から 1 つ削除すると、残りのフィールドもすべて「変更された」扱いになりがち
  • 対策:配列操作後に reset でデフォルト値を更新するか、独自にフラグ管理する

参考:

TanStack Form の配列操作

  • TanStack Form では、各フィールドが独立した isDirty フラグを持つ。
    • そのため、配列の要素を追加・削除・移動しても、他の要素の isDirty 状態には自動的に影響しない。
    • 例えば、あるフィールドを dirty 状態(isDirty=true)にした後、値を元に戻しても isDirtyfalse にはならない。
    • これは「一度 dirty になったら履歴として残る」という設計思想によるもの。
  • reset メソッドを使うことで、すべての isDirty フラグを false にリセットできる。
  • 配列の大幅な変更や初期化が必要な場合は、reset を活用することで isDirty 状態をリセットできる。

このように、TanStack Form では配列操作と isDirty の関係が明確に分離されており、履歴型の dirty 判定が行われます。

参考:

4 Tanstack Form で isDefaultValue の導入が進行中 🌱

これまで見てきたように、TanStack Form は現在の isDirty フラグが履歴指向であるのに対し、新たに検討が進められている isDefaultValue は現在値と初期値の比較に基づく差分指向です。

以下の PR で対応が進められています
https://github.com/TanStack/form/pull/1456

これが入れば Dirty キー抽出は ワンライナーで可能になります。

const dirtyNow = Object.entries(form.state.fieldMeta)
  .filter(([, m]) => !m.isDefaultValue)
  .map(([k]) => k);

既存 isDirty(履歴指向)と isDefaultValue(差分指向)が並立し、
RHF と同じ「現在値ベースの Dirty 判定」が TanStack でも簡単に書けるようになります。

4-1 isDefaultValue の内部設計と最適化

TanStack Form では従来、一度編集されたフィールドは元の値に戻しても isDirty=true のままでした。
これに対し、「現在値 = デフォルト値なら dirty でない」と判定できる isDefaultValue メタフラグが追加されます。

https://github.com/harry-whorlow/form/blob/cebae4fbaf439c5d5a5ac89b2175b073ab1b50c2/packages/form-core/src/FormApi.ts#L1801-L1816

両フラグの使い分け

フラグ 特性 用途例
isDirty 履歴型:一度変更されたら true のまま 「ユーザーが編集操作を行ったか」の判定
isDefaultValue 差分型:現在値がデフォルト値と一致すれば true 「現在値が初期値から変更されているか」の判定
// 両フラグの挙動の違い
field.setValue("new-value");
console.log(field.getMeta().isDirty); // true
console.log(field.getMeta().isDefaultValue); // false

field.setValue("default-value"); // 元の値に戻す
console.log(field.getMeta().isDirty); // true(変わらず)
console.log(field.getMeta().isDefaultValue); // true(デフォルトと一致)

field.resetField();
console.log(field.getMeta().isDirty); // false(リセット)
console.log(field.getMeta().isDefaultValue); // true

4-2 RHF と TanStack Form の比較詳細

実装の複雑さをざっくり比較

観点 React Hook Form (RHF) TanStack Form (+ isDefaultValue)
Dirty 情報の持ち方 dirtyFields というネストツリー。フィールドが深くなるほど構造もどんどん複雑に。 各フィールドに**isDefaultValueの真偽値**を持たせるだけ
更新アルゴリズム 1. パス文字列(user.name / tags[0])を解析
2. set/unsetでツリーを再帰構築
3. 配列のズレ補正
4. 差分判定
1. 値が変わるたび===で「初期値か」だけ再計算
2. メタ情報を O(1)で更新
Dirty キー取得 ユーティリティで再帰 flattenが必要 filtermapで一発

RHF は多機能ですが、ツリー構造の操作が少し複雑になりがちです。一方、TanStack Form はシンプルに管理できるため、コードも計算もすっきりと書けると感じます。

柔軟性・拡張性の違い

何を変えたいか RHF TanStack Form
特定フィールドだけ初期値を再設定 reset({ … })で全体置き換えが基本 form.resetField('name', { defaultValue: 'Bob'}) ✔︎
送信後に新しいデフォルト値を注入 reset(values)で全フィールド一括 form.setDefaultValues(values)が公式 API ✔︎
Dirty 判定ロジックを上書き 内部実装依存でカスタムしづらい isDefaultValueを直接操作・独自メタ追加も OK ✔︎
dirtyFieldsisDirtyの整合 場合によっては手動リセットが必要 isDirty(履歴)とisDefaultValue(差分)が明確に分離 ✔︎

RHF は基本的に公式が用意した枠組みの中で工夫するイメージですが、TanStack Form はメタ情報の設計を後から自由に拡張できるため、より柔軟にさまざまな要件に対応しやすいと感じます。


4-4 isDefaultValue 導入後の簡略化実装例

isDefaultValue が導入されたことにより、1-2「Dirty パス配列を返すカスタムフック」や 1-4「差分オブジェクトで PATCH 送信」で示したような、自前での複雑な比較ロジックは大部分が不要になり、カスタムフックの実装は大幅に簡略化されます。

現在の useDirtyKeys が以下のようになります

/** 現在 Dirty なフィールドパスを配列で返す (isDefaultValue 版) */
export const useDirtyKeys = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  // fieldMeta を購読
  const fieldMeta = useStore(form.store, (s) => s.fieldMeta);

  // !isDefaultValue のフィールドだけを抽出
  return Object.entries(fieldMeta)
    .filter(([, meta]) => !meta.isDefaultValue)
    .map(([key]) => key);
};

現在の useDirtyPayload も簡略化できる想定です。

/** 変更部分だけ抽出して返す (isDefaultValue 版) */
export const useDirtyPayload = <TValues extends Record<string, unknown>>(
  form: FormOf<TValues>
) => {
  const values = useStore(form.store, (s) => s.values);
  const fieldMeta = useStore(form.store, (s) => s.fieldMeta);

  // 変更されたフィールドだけを含むオブジェクトを構築
  return Object.entries(fieldMeta)
    .filter(([, meta]) => !meta.isDefaultValue)
    .reduce((result, [key]) => {
      // getValueByPath, setValueByPath は1-4で示したもの
      const valueToSet = getValueByPath(values, key);
      if (valueToSet !== undefined) {
        setValueByPath(result, key, valueToSet);
      }
      return result;
    }, {} as Partial<TValues>);
};

このように、これまで必要だった複雑な再帰処理や深い比較を行う実装が、シンプルなフィルタリングと値の抽出だけで済むようになります。

isDefaultValue の導入によって、TanStack Form の実装はより簡潔になり、コード量も大きく減らせる見込みです。

まとめ 📌

本記事では、TanStack Form と React Hook Form における「Dirty 判定」の挙動の違いについて解説しました。

両ライブラリは根本的な検知方式が異なります。React Hook Form は変更されたフィールドをネスト構造で保持し追跡するのに対し、TanStack Form は現状では自前で実装した比較ロジックで初期値との差分を検出する必要があります。

また、TanStack Form は isDefaultValue の導入が進行中で、将来的には履歴型と差分型の両方のアプローチを柔軟に使い分けられるようになる予定です。

両ライブラリにはそれぞれメリットとデメリットがあり、どちらが絶対的に優れているというより、特定のユースケースに最適なライブラリを選ぶことが大切に感じます。フォームの複雑さや要件に応じて、この記事で紹介した知見を参考にしていただければ幸いです。

以上です!

Discussion