タブレット スタンド アルミ ホルダー 角度調整可能 Lomicall stand : 卓上 縦置き スタンド タブレット 置き台 デスク台 立てる 設置 aluminium テレワーク 在宅 ワーク Zoom 会議 タブレット対応(4~13'') ミニ エア プロ ipad 10 第十世代 ipad9 第九世代 ipad Air mini Pro第六世代 S7 S8 Note 対応 - シルバー
¥1,759 (2025年4月30日 13:06 GMT +09:00 時点 - 詳細はこちら価格および発送可能時期は表示された日付/時刻の時点のものであり、変更される場合があります。本商品の購入においては、購入の時点で当該の Amazon サイトに表示されている価格および発送可能時期の情報が適用されます。)
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 となります。
TanStack Form には 「現在値 ≠ 初期値」を返すフラグがまだ無い ため、defaultValues
と useStore(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 }) => {
console.log(value);
},
});
1-2 Dirty パス配列を返すカスタムフック
このカスタムフックは、TanStack Form の useStore
を使って現在のフォーム値と初期値を比較し、変更されたフィールドのパスを平坦配列で返します。
この実装は、次章で紹介する RHF のディスカッションで提案されている方法を参考にしています。
import { useStore } from "@tanstack/react-form";
import isEqual from "lodash/isEqual";
type FormOfT> = ReactFormExtendedApiT>;
export const useDirtyKeys = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
const values = useStore(form.store, (s) => s.values);
const defaults = form.options.defaultValues;
const getDirtyFieldPaths = (
current: any,
defaultValue: any,
basePath = ""
): string[] => {
if (
typeof current !== "object" ||
current === null ||
defaultValue === null
) {
return !isEqual(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 (!isEqual(currentValue, defaultVal)) {
return typeof currentValue === "object" && currentValue !== null
? getDirtyFieldPaths(currentValue, defaultVal, path)
: [path];
}
return [];
});
};
return getDirtyFieldPaths(values, defaults);
};
const dirtyKeys = useDirtyKeys(form);
console.log(dirtyKeys);
なぜ lodash.isEqual
を使うのか?
単純に JSON.stringify
で比較する方法も考えられますが、オブジェクトのキー順序の違いや Date
オブジェクトの扱いなどで予期せぬ誤差が生じる可能性があります。そのため、オブジェクトの深い比較に特化した lodash.isEqual
のようなユーティリティを利用することで、より確実に値の同一性を判定できます。
1-3 派生ストアでパフォーマンス最適化
form.store.derive()
を使うことで Derived Store(派生ストア)を作成できます。
Derived Store は、元の Store の値から新しい値(ここでは「差分キー配列」)を計算して保持するストアです。
export const createDirtyStore = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
return form.store.derive((s) =>
getDirtyFieldPaths(s.values, form.options.defaultValues)
);
};
export const useDirtyKeysDerived = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
const dirtyStore = useMemo(() => createDirtyStore(form), [form]);
return useStore(dirtyStore);
};
const dirtyKeys = useDirtyKeysDerived(form);
console.log(dirtyKeys);
ここで重要なのは、form.store.derive()
のコールバック内で deep-diff(getDirtyFieldPaths
)の計算を行っている点です。
この派生ストアは state.values
と form.options.defaultValues
だけを購読しているため、Dirty 状態の変化以外では再レンダが発生しません。
つまり、「差分キー配列だけを 1 つの store に閉じ込めて購読」することで、フォーム全体のパフォーマンスを最適化できます。
大規模なフォームでは、この派生ストアを使うことでパフォーマンスを大幅に向上させることができます。
1-4 差分オブジェクトで PATCH 送信
このカスタムフックは、現在のフォーム値と初期値を比較して変更された部分だけを含むオブジェクトを返します。
こちらも、RHF のコミュニティディスカッションで提案されている実装を参考にしています。
type FormOfT> = ReactFormExtendedApiT>;
export const useDirtyPayload = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
const vals = useStore(form.store, (s) => s.values);
const defs = form.options.defaultValues;
const getDirtyValues = T extends Recordstring, any>>(
current: T,
defaults: PartialT>
): PartialT> => {
if (isEqual(current, defaults)) return {};
if (
typeof current !== "object" ||
current === null ||
Array.isArray(current)
) {
return current;
}
return Object.keys({ ...current, ...defaults }).reduce((acc, key) => {
const currentValue = current[key];
const defaultValue = defaults?.[key];
if (!isEqual(currentValue, defaultValue)) {
const dirtyValue =
typeof currentValue === "object" &&
currentValue !== null &&
!Array.isArray(currentValue)
? getDirtyValues(currentValue, defaultValue as any)
: currentValue;
if (
typeof dirtyValue === "object" &&
Object.keys(dirtyValue).length === 0
) {
return acc;
}
return { ...acc, [key]: dirtyValue };
}
return acc;
}, {} as PartialT>);
};
return getDirtyValues(vals, defs);
};
const payload = useDirtyPayload(form);
console.log(payload);
fetch("/api/profile", { method: "PATCH", body: JSON.stringify(payload) });
送信後にデフォルト値を更新したい場合
ユーザーが「保存」した直後に その時点の値 を新しい defaultValues
として採用したいケースでは、次のどちらかを呼び出します。
form.reset(newValues);
form.setDefaultValues(newValues);
これにより form.options.defaultValues
が書き換わります。
RHF には formState.dirtyFields
が標準で用意されており、これを活用して変更されたフィールドの処理が可能です。
2-1 変更値を抽出する getDirtyValues
関数
React Hook Form の dirtyFields
を効果的に活用するには、変更された値だけを抽出するユーティリティがあると便利です。
React Hook Form のコミュニティディスカッションで提案されている getDirtyValues
関数が最も実用的なアプローチだと思います。
export type DirtyFieldsType =
| boolean
| null
| {
[key: string]: DirtyFieldsType;
}
| DirtyFieldsType[];
export function getDirtyValuesT extends Recordstring, any>>(
dirtyFields: PartialRecordkeyof T, DirtyFieldsType>>,
values: T
): PartialT> {
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 Recordstring, any>, values[key])
: values[key];
return { ...prev, [key]: isArray ? values[key] : nestedValue };
}, {} as PartialT>);
return dirtyValues;
}
この getDirtyValues
関数で実現できる主な機能:
- PATCH リクエスト向けデータ生成: 変更されたフィールドの値だけを含むオブジェクトが得られる
-
変更フィールドの把握:
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,
} = useFormFormValues>({
defaultValues,
});
const onSubmit = (data: FormValues) => {
const dirtyValues = getDirtyValues(dirtyFields, data);
console.log(dirtyValues);
const diffKeys = Object.keys(dirtyValues);
console.log(diffKeys);
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 });
ポイントは useForm
で defaultValues を必ず指定 すること。
デフォルト値が未定義だと比較対象がなくなり isDirty
が期待とずれるので注意です。
useController
を挟まずに register
だけで取得したい場合はformState.dirtyFields[name]
を見るか、getFieldState(name)
を呼びます。
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
の更新が最適化されていることがわかります。
具体的には以下のようなロジックで更新されています。
- フィールド更新時、購読フラグが立っている場合のみ
dirtyFields
全体を再計算 - 通常は変更のあった特定フィールドだけ
set
/unset
するため効率的 -
dirtyFields
オブジェクトはミュータブルに更新され、必要な再レンダー通知だけを行う設計
3-2 TanStack Form のストア購読と派生値
TanStack Form は Store 指向 のため、購読とリアクティビティに異なるアプローチを取ります。
const dirtyCount = form.useStore(
(s) => Object.values(s.fieldMeta).filter((m) => m.isDirty).length
);
ストア購読の最適化
TanStack Form では、useStore
フックを使ってフォーム状態を購読します。公式ドキュメントでも「セレクタを使って必要な状態だけを購読すること」が推奨されています。
-
form.useStore(selector)
は内部的にuseSyncExternalStoreWithSelector
を利用しており、セレクタが返す値を浅い比較で判定します。 - セレクタで選択した値だけを監視し、前回と異なる場合のみ再レンダリングが発生します。
-
オブジェクトをそのまま返さず、プリミティブや配列・値そのものを返すことが重要です(例:
{ a: state.a }
ではなくstate.a
を直接返す)。これにより、不要な再レンダリングを防げます。
また、公式ドキュメントでも「セレクタを省略すると不要な再レンダリングが発生する可能性がある」と警告されています。
最適化のためには、常にセレクタを明示的に指定し、必要な値だけを購読することがベストプラクティスです。
派生ストアによる計算コスト最適化
既に 1-3 で触れた内容ではありますが、パフォーマンスに関して再度整理します。
派生ストアを再掲
export const createDirtyStore = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
return form.store.derive((s) =>
getDirtyFieldPaths(s.values, form.options.defaultValues)
);
};
export const useDirtyKeysDerived = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
const dirtyStore = useMemo(() => createDirtyStore(form), [form]);
return useStore(dirtyStore);
};
const dirtyKeys = useDirtyKeysDerived(form);
console.log(dirtyKeys);
-
form.store.derive()
で 派生値 を遅延評価で作成可能 - 依存元(フォーム状態)が変わったときだけ再計算されるため効率的
- 複雑な深い比較などを派生ストアの中だけで行い、比較結果だけを返すことでパフォーマンス向上
- コンポーネントは計算結果だけを購読するため、再レンダリングが最小限に抑えられる
参考:
3-3 配列操作時の注意点
RHF の配列操作と dirty 判定
-
useFieldArray
で要素を追加・削除すると、デフォルト値との比較で 配列全体 が変化したと判断される - 例:10 個の配列から 1 つ削除すると、残りのフィールドもすべて「変更された」扱いになりがち
- 対策:配列操作後に
reset
でデフォルト値を更新するか、独自にフラグ管理する
参考:
TanStack Form の配列操作
- TanStack Form では、各フィールドが独立した
isDirty
フラグを持つ。- そのため、配列の要素を追加・削除・移動しても、他の要素の
isDirty
状態には自動的に影響しない。 - 例えば、あるフィールドを dirty 状態(
isDirty=true
)にした後、値を元に戻してもisDirty
はfalse
にはならない。 - これは「一度 dirty になったら履歴として残る」という設計思想によるもの。
- そのため、配列の要素を追加・削除・移動しても、他の要素の
-
reset
メソッドを使うことで、すべてのisDirty
フラグをfalse
にリセットできる。 - 配列の大幅な変更や初期化が必要な場合は、
reset
を活用することでisDirty
状態をリセットできる。
このように、TanStack Form では配列操作と isDirty
の関係が明確に分離されており、履歴型の dirty 判定が行われます。
参考:
これまで見てきたように、TanStack Form は現在の isDirty
フラグが履歴指向であるのに対し、新たに検討が進められている isDefaultValue
は現在値と初期値の比較に基づく差分指向です。
以下の PR で対応が進められています
これが入れば 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
メタフラグが追加されます。
両フラグの使い分け
フラグ | 特性 | 用途 |
---|---|---|
isDirty |
履歴型:一度変更されたら true のまま |
「編集履歴があるか」の判定 |
isDefaultValue |
差分型:現在値がデフォルト値と一致すれば true
|
「現在値が変わっているか」の判定 |
field.setValue("new-value");
console.log(field.getMeta().isDirty);
console.log(field.getMeta().isDefaultValue);
field.setValue("default-value");
console.log(field.getMeta().isDirty);
console.log(field.getMeta().isDefaultValue);
field.resetField();
console.log(field.getMeta().isDirty);
console.log(field.getMeta().isDefaultValue);
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が必要 |
filter とmap で一発 |
RHF は多機能ですが、ツリー構造の操作が少し複雑になりがちです。一方、TanStack Form はシンプルに管理できるため、コードも計算もすっきりと書けると感じます。
柔軟性・拡張性の違い
何を変えたいか | RHF | TanStack Form (+ isDefaultValue) |
---|---|---|
特定フィールドだけ初期値を再設定 |
reset({ … }) で全体置き換えが基本 |
form.resetField('name', { defaultValue: 'Bob'}) ✔︎ |
送信後に新しいデフォルト値を注入 |
reset(values) で全フィールド一括 |
form.setDefaultValues(values) が公式 API ✔︎ |
Dirty 判定ロジックを上書き | 内部実装依存でカスタムしづらい |
isDefaultValue を直接操作・独自メタ追加も OK ✔︎ |
dirtyFields とisDirty の整合 |
場合によっては手動リセットが必要 |
isDirty (履歴)とisDefaultValue (差分)が明確に分離 ✔︎ |
RHF は基本的に公式が用意した枠組みの中で工夫するイメージですが、TanStack Form はメタ情報の設計を後から自由に拡張できるため、より柔軟にさまざまな要件に対応しやすいと感じます。
4-4 isDefaultValue 導入後の簡略化実装例
isDefaultValue
が導入されると、1-2, 1-4 で示した複雑な実装は大幅に簡略化されます。以下に具体例を示します。
現在の useDirtyKeys
が以下のようになります
export const useDirtyKeys = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
const fieldMeta = useStore(form.store, (s) => s.fieldMeta);
return Object.entries(fieldMeta)
.filter(([, meta]) => !meta.isDefaultValue)
.map(([key]) => key);
};
現在の useDirtyPayload
も簡略化できる想定です。
export const useDirtyPayload = TValues extends Recordstring, unknown>>(
form: FormOfTValues>
) => {
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]) => {
const parts = key.split(".");
const setNestedValue = (obj, valueObj, pathParts) => {
if (pathParts.length === 1) {
return {
...obj,
[pathParts[0]]: valueObj[pathParts[0]],
};
}
const [head, ...rest] = pathParts;
return {
...obj,
[head]: setNestedValue(obj[head] || {}, valueObj[head], rest),
};
};
return setNestedValue(result, values, parts);
}, {});
};
このように、これまで必要だった複雑な再帰処理や深い比較を行う実装が、シンプルなフィルタリングと値の抽出だけで済むようになります。
isDefaultValue
の導入によって、TanStack Form の実装はより簡潔になり、コード量も大きく減らせる見込みです。
本記事では、TanStack Form と React Hook Form における「Dirty 判定」の挙動の違いについて解説しました。
両ライブラリは根本的な検知方式が異なります。React Hook Form は変更されたフィールドをネスト構造で保持し追跡するのに対し、TanStack Form は現状では自前で実装した比較ロジックで初期値との差分を検出する必要があります。
また、TanStack Form は isDefaultValue
の導入が進行中で、将来的には履歴型と差分型の両方のアプローチを柔軟に使い分けられるようになる予定です。
両ライブラリにはそれぞれメリットとデメリットがあり、どちらが絶対的に優れているというより、特定のユースケースに最適なライブラリを選ぶことが大切に感じます。フォームの複雑さや要件に応じて、この記事で紹介した知見を参考にしていただければ幸いです。
以上です!
Views: 0