TanStack Form v1.21.3 配列削除で falsy 値が undefined に化けるバグの解析と回避策
配列フィールドを扱っているときに「空文字が消えた?」「0 が undefined
になった?」と違和感を覚えたことはありませんか?
私も最初は自分のコードを疑いましたが、調べてみるとこれは TanStack Form 側の既知バグ でした(Issue #1439)。
この記事では内部処理を追跡しながら、なぜ falsy 値が undefined
に化けるのか を解説し、修正が入るまでの回避策を整理します。
TL;DR
- 配列要素を削除すると
'' / 0 / false / null
がundefined
に変換される - 原因は
FieldApi.update
内のsetValue(val || defaultValue)
- PR #1440 で一度修正が提案されたが未解決
- 回避策は以下の3つ
-
defaultValue
を明示する -
useEffect
でundefined
を補正する - Zod で正規化する
-
事象
削除操作を行うと、後続要素の値がシフトされる際に falsy 値が undefined
に化けます。
削除前
items[0] = { id: "id_1", value: "will be deleted" }
items[1] = { id: "id_2", value: "" } // ← これがindex 0にシフト
削除後(❌ バグ発生)
items[0] = { id: "id_2", value: undefined } // ""がundefinedに!
0
、false
、null
も同様に消えてしまいます。
再現コード
import { useForm } from '@tanstack/react-form'
function App() {
const form = useForm({
defaultValues: {
items: []
}
})
return (
<form.Field name="items" mode="array">
{(field) => (
<>
<button onClick={() => field.pushValue({ id: Date.now(), value: '' })}>
""を追加
</button>
{field.state.value.map((item, index) => (
<div key={item.id}> {/* React key にIDを使用 */}
<form.Field name={`items[${index}].value`}>
{(subField) => (
<>
<span>
Value: {subField.state.value === undefined ? '❌ undefined' : `"${subField.state.value}"`}
</span>
<button onClick={() => field.removeValue(index)}>削除</button>
</>
)}
</form.Field>
</div>
))}
</>
)}
</form.Field>
)
}
内部処理の流れ
この現象は「削除」から「再レンダリング」までの間に発生します。シーケンス図で見ると以下のようになります。
では、実際のコードと処理の流れを追ってみましょう。
1. 削除処理を実行
<form.Field name="items" mode="array">
{(field) => (
<button onClick={() => field.removeValue(0)}>削除</button>
)}
</form.Field>
2. FieldApi.removeValue の実行
ここで実際の削除処理は FormApi に委譲されます。
3. FormApi.removeFieldValue の実行
- 配列から該当要素を削除し、インデックスを詰め直す
- メタ情報(エラー、dirty状態など)をシフトする
-
最後のインデックスのフィールドを完全削除(これが
undefined
の原因) - 再検証を実行する
4. useField による再レンダリング
削除によってフィールド名が items[1].value → items[0].value
のように変更されるため、update
が呼ばれます。
5. FieldApi.update(バグ発生箇所)
update = (opts: FieldApiOptions) => {
const nameHasChanged = this.name !== opts.name
this.name = opts.name
// 重要: この条件を満たす場合のみ、バグが発生する
if ((this.state.value as unknown) === undefined) {
const formDefault = getBy(opts.form.options.defaultValues, opts.name)
const defaultValue = (opts.defaultValue as unknown) ?? formDefault
if (nameHasChanged) {
// ここが問題!setValueのupdater関数が呼ばれる
this.setValue((val) => (val as unknown) || defaultValue, { dontUpdateMeta: true })
}
}
}
バグが発生する条件:
-
(this.state.value as unknown) === undefined
(現在の値が未定義) -
nameHasChanged === true
(フィールド名が変更された)
削除時は両方の条件を満たすため、setValue
の updater
関数で ""、 0、false、null
といった falsy
値が ||
によって undefined
に置き換えられてしまう。
根本原因
直接の原因は val || defaultValue
ですが、より本質的には「配列削除時にフィールド名が変わる設計」と「フィールドの初期化処理」の組み合わせにあります。
なぜ両条件を満たすのか:
- 配列削除により
items[1]
→items[0]
にインデックスがシフト -
重要:
FormApi.removeFieldValue
で最後のインデックス(items[1]
)の全フィールドがdeleteField
により完全削除される- Storeから値が消去される(
deleteBy(newState.values, f)
) - フィールド情報も削除される(
delete this.fieldInfo[f]
)
- Storeから値が消去される(
- React の再レンダリングで、FieldApiが新しい名前で更新される
-
useField
が新しい名前(items[0].value
)でfieldApi.update
を呼ぶ - この時点で
this.state.value
はundefined
(Storeから値が削除されているため) -
nameHasChanged
がtrue
(名前がitems[1].value
→items[0].value
に変化) - 両条件を満たすため、
setValue
のupdater関数が実行される - Storeから値を取得しようとするが、
||
演算子により falsy 値がundefined
に変換される
メンテナーも PR #1440 で「本来はStoreレベルで修正すべき」とコメントしていました。
当面の回避策(どれを選ぶ?)
defaultValue
を設定する(安全だが初期値と競合しやすい)
1. <form.Field
name={`items[${index}].value`}
defaultValue=""
>
{(subField) => (
)}
</form.Field>
useEffect
で補正する
2. const field = useField({ form, name })
useEffect(() => {
if (field.state.value === undefined) {
field.setValue('')
}
}, [field.state.value])
- ✅ 将来公式で修正されたら削除しやすい
- ⚠️ 各コンポーネントで記述が必要
3. Zod で正規化する
z.object({
value: z.string().default("").or(z.undefined()).transform(v => v ?? "")
})
- ✅ 入力値を統一的に正規化できる
- ⚠️ スキーマが大量にある場合はメンテが大変
プロダクトでの選択
私は useEffect で補正 を選びました。
理由は:
- プロダクト内にスキーマが大量にあるため、Zod 側で正規化すると影響範囲が大きすぎる
-
defaultValue
はdefaultValues
を上書きしてしまうため採用しづらい
結果として、useEffect による補正が最も影響範囲を小さく抑えられ、将来的な修正にも対応しやすいと判断しました。
おわりに
今回のバグ調査を通じて、TanStack Form の内部構造(FieldApi、FormApi、Store の3層構造)や、配列フィールドの管理方法について深く理解する良い機会になりました。
時間があるときに、この問題を解決する PR を作成してみるのも面白そうです。
この記事が、同じ問題に遭遇した方の参考になれば幸いです。
関連リンク
Discussion